AppHeader.tsx
1 import * as React from 'react'; 2 import { useState, useRef, useEffect } from 'react'; 3 import { Menu, X, Search, Sun, Moon, ExternalLink, ChevronDown, Globe } from 'lucide-react'; 4 import { cn } from '../../lib/utils'; 5 import { ConnectWallet } from '../ConnectWallet'; 6 import { useTheme } from '../../hooks/useTheme'; 7 8 export interface NavItem { 9 name: string; 10 href: string; 11 external?: boolean; 12 } 13 14 export interface EcosystemApp { 15 name: string; 16 href: string; 17 description: string; 18 } 19 20 export interface AppHeaderProps { 21 /** Service name displayed after the logo (e.g., "Wallet", "Governor") */ 22 serviceName: string; 23 /** App-specific navigation items */ 24 navigation?: NavItem[]; 25 /** Show search icon */ 26 showSearch?: boolean; 27 /** Search click handler */ 28 onSearchClick?: () => void; 29 /** Show connect wallet button */ 30 showConnectWallet?: boolean; 31 /** Connect wallet handler */ 32 onConnectWallet?: () => void; 33 /** Whether wallet is connected */ 34 walletConnected?: boolean; 35 /** Connected wallet address (truncated) */ 36 walletAddress?: string; 37 /** Current path for active nav highlighting */ 38 currentPath?: string; 39 /** Custom link component (for framework integration) */ 40 LinkComponent?: React.ComponentType<{ href: string; className?: string; children: React.ReactNode; onClick?: () => void }>; 41 /** Ecosystem home URL (defaults to "/") */ 42 ecosystemHref?: string; 43 /** Current language code */ 44 currentLanguage?: string; 45 /** Language change handler */ 46 onLanguageChange?: (code: string) => void; 47 /** Translated ecosystem apps (overrides default) */ 48 ecosystemApps?: EcosystemApp[]; 49 /** Label for "Ecosystem" section in mobile menu */ 50 mobileMenuEcosystemLabel?: string; 51 /** Additional class names */ 52 className?: string; 53 } 54 55 /** Default ecosystem apps for the service name dropdown */ 56 const defaultEcosystemApps: EcosystemApp[] = [ 57 { name: 'Network', href: 'https://www.ac-dc.network', description: 'Ecosystem home' }, 58 { name: 'Wallet', href: 'https://wallet.ac-dc.network', description: 'Manage your AX & DX' }, 59 { name: 'Governor', href: 'https://governor.ac-dc.network', description: 'Participate in governance' }, 60 { name: 'Scanner', href: 'https://scanner.ac-dc.network', description: 'Explore the blockchain' }, 61 { name: 'Messenger', href: 'https://messenger.ac-dc.network', description: 'Encrypted messaging' }, 62 { name: 'Docs', href: 'https://docs.ac-dc.network', description: 'Developer documentation' }, 63 { name: 'Forge', href: 'https://forge.ac-dc.network', description: 'Build with ADL' }, 64 ]; 65 66 /** Supported languages - sorted by global speaker count, using ISO 639-1 codes */ 67 const languages = [ 68 { code: 'en', name: 'English', label: 'EN' }, 69 { code: 'zh-CN', name: '简体中文', label: 'ZH' }, 70 { code: 'hi', name: 'हिन्दी', label: 'HI' }, 71 { code: 'es', name: 'Español', label: 'ES' }, 72 { code: 'ar', name: 'العربية', label: 'AR', rtl: true }, 73 { code: 'bn', name: 'বাংলা', label: 'BN' }, 74 { code: 'pt', name: 'Português', label: 'PT' }, 75 { code: 'ru', name: 'Русский', label: 'RU' }, 76 { code: 'ja', name: '日本語', label: 'JA' }, 77 { code: 'de', name: 'Deutsch', label: 'DE' }, 78 { code: 'ko', name: '한국어', label: 'KO' }, 79 { code: 'fr', name: 'Français', label: 'FR' }, 80 { code: 'vi', name: 'Tiếng Việt', label: 'VI' }, 81 { code: 'tr', name: 'Türkçe', label: 'TR' }, 82 { code: 'it', name: 'Italiano', label: 'IT' }, 83 { code: 'th', name: 'ไทย', label: 'TH' }, 84 { code: 'pl', name: 'Polski', label: 'PL' }, 85 { code: 'uk', name: 'Українська', label: 'UK' }, 86 { code: 'nl', name: 'Nederlands', label: 'NL' }, 87 { code: 'id', name: 'Bahasa Indonesia', label: 'ID' }, 88 { code: 'ro', name: 'Română', label: 'RO' }, 89 { code: 'el', name: 'Ελληνικά', label: 'EL' }, 90 { code: 'cs', name: 'Čeština', label: 'CS' }, 91 { code: 'hu', name: 'Magyar', label: 'HU' }, 92 { code: 'sv', name: 'Svenska', label: 'SV' }, 93 { code: 'he', name: 'עברית', label: 'HE', rtl: true }, 94 { code: 'da', name: 'Dansk', label: 'DA' }, 95 { code: 'fi', name: 'Suomi', label: 'FI' }, 96 { code: 'no', name: 'Norsk', label: 'NO' }, 97 { code: 'sk', name: 'Slovenčina', label: 'SK' }, 98 { code: 'hr', name: 'Hrvatski', label: 'HR' }, 99 { code: 'bg', name: 'Български', label: 'BG' }, 100 { code: 'sr', name: 'Српски', label: 'SR' }, 101 { code: 'sl', name: 'Slovenščina', label: 'SL' }, 102 { code: 'lt', name: 'Lietuvių', label: 'LT' }, 103 { code: 'lv', name: 'Latviešu', label: 'LV' }, 104 { code: 'et', name: 'Eesti', label: 'ET' }, 105 { code: 'ms', name: 'Bahasa Melayu', label: 'MS' }, 106 { code: 'tl', name: 'Tagalog', label: 'TL' }, 107 { code: 'fa', name: 'فارسی', label: 'FA', rtl: true }, 108 ]; 109 110 /** Default link component (plain anchor) */ 111 const DefaultLink: React.FC<{ href: string; className?: string; children: React.ReactNode; onClick?: () => void }> = ({ 112 href, 113 className, 114 children, 115 onClick, 116 }) => ( 117 <a href={href} className={className} onClick={onClick}> 118 {children} 119 </a> 120 ); 121 122 /** 123 * AppHeader - Standardized header component for all ACDC apps 124 * 125 * Features: 126 * - Logo with service name 127 * - Configurable navigation 128 * - Apps dropdown (main site only) 129 * - Search button (optional) 130 * - Connect Wallet button (optional) 131 * - Theme toggle 132 * - Responsive mobile menu (hamburger on far left) 133 */ 134 const AppHeader = React.forwardRef<HTMLElement, AppHeaderProps>( 135 ( 136 { 137 serviceName, 138 navigation = [], 139 showSearch = false, 140 onSearchClick, 141 showConnectWallet = false, 142 onConnectWallet, 143 walletConnected = false, 144 walletAddress, 145 currentPath = '/', 146 LinkComponent = DefaultLink, 147 ecosystemHref = '/', 148 currentLanguage = 'en', 149 onLanguageChange, 150 ecosystemApps = defaultEcosystemApps, 151 mobileMenuEcosystemLabel = 'Ecosystem', 152 className, 153 }, 154 ref 155 ) => { 156 const [mobileMenuOpen, setMobileMenuOpen] = useState(false); 157 const [serviceMenuOpen, setServiceMenuOpen] = useState(false); 158 const [langMenuOpen, setLangMenuOpen] = useState(false); 159 const [langMenuPos, setLangMenuPos] = useState<{ top: number; left: number } | null>(null); 160 const serviceMenuRef = useRef<HTMLDivElement>(null); 161 const langMenuRef = useRef<HTMLDivElement>(null); 162 const serviceHoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); 163 const { theme, toggleTheme } = useTheme(); 164 165 const Link = LinkComponent; 166 // Normalize language code (e.g., en-US -> en, zh-CN stays zh-CN) 167 const normalizedLang = currentLanguage?.includes('-') && !currentLanguage.startsWith('zh') 168 ? currentLanguage.split('-')[0] 169 : currentLanguage; 170 const currentLang = languages.find(l => l.code === normalizedLang || l.code === currentLanguage) || languages[0]; 171 172 // Calculate language menu position when opened 173 useEffect(() => { 174 if (langMenuOpen && langMenuRef.current) { 175 const rect = langMenuRef.current.getBoundingClientRect(); 176 const menuWidth = 176; // w-44 = 11rem = 176px 177 const padding = 8; 178 179 // Calculate left position to keep menu within viewport 180 let left = rect.left; 181 if (left + menuWidth > window.innerWidth - padding) { 182 left = window.innerWidth - menuWidth - padding; 183 } 184 if (left < padding) { 185 left = padding; 186 } 187 188 setLangMenuPos({ 189 top: rect.bottom + 4, 190 left, 191 }); 192 } 193 }, [langMenuOpen]); 194 195 // Handle service menu hover with 1 second delay to close 196 const handleServiceMouseEnter = () => { 197 if (serviceHoverTimeout.current) { 198 clearTimeout(serviceHoverTimeout.current); 199 serviceHoverTimeout.current = null; 200 } 201 setServiceMenuOpen(true); 202 }; 203 204 const handleServiceMouseLeave = () => { 205 serviceHoverTimeout.current = setTimeout(() => { 206 setServiceMenuOpen(false); 207 }, 1000); 208 }; 209 210 // Cleanup timeout on unmount 211 useEffect(() => { 212 return () => { 213 if (serviceHoverTimeout.current) { 214 clearTimeout(serviceHoverTimeout.current); 215 } 216 }; 217 }, []); 218 219 // Close lang menu when clicking outside 220 useEffect(() => { 221 const handleClickOutside = (event: MouseEvent) => { 222 if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) { 223 setLangMenuOpen(false); 224 } 225 }; 226 document.addEventListener('mousedown', handleClickOutside); 227 return () => document.removeEventListener('mousedown', handleClickOutside); 228 }, []); 229 230 return ( 231 <header 232 ref={ref} 233 className={cn( 234 'sticky top-0', 235 'bg-bg-primary/80 backdrop-blur-lg', 236 'border-b border-border-subtle', 237 className 238 )} 239 style={{ zIndex: 1000 }} 240 > 241 <nav className="max-w-[1440px] mx-auto px-4 lg:px-8"> 242 <div className="flex items-center justify-between h-16"> 243 {/* Left section: Mobile hamburger + Logo with Service Dropdown */} 244 <div className="flex items-center gap-3"> 245 {/* Mobile hamburger - far left */} 246 <button 247 onClick={() => setMobileMenuOpen(!mobileMenuOpen)} 248 className="md:hidden p-2 -ml-2 text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors" 249 aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'} 250 > 251 {mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />} 252 </button> 253 254 {/* Logo lockup — flex items-center matching mobile mock */} 255 <div className="flex items-center font-bold text-[21px]"> 256 {/* α|δ textmark — links to ecosystem home */} 257 <Link 258 href={ecosystemHref} 259 className="inline-flex items-center hover:opacity-80 transition-opacity" 260 > 261 <span className="text-alpha-500">α</span> 262 <span className="text-text-tertiary">|</span> 263 <span className="text-delta-500">δ</span> 264 </Link> 265 266 {/* :: ServiceName with hover dropdown */} 267 <div 268 className="relative inline-flex items-center ml-[5px] cursor-default" 269 ref={serviceMenuRef} 270 onMouseEnter={handleServiceMouseEnter} 271 onMouseLeave={handleServiceMouseLeave} 272 > 273 <span className="font-bold text-text-secondary">::</span> 274 <span className="relative top-[3px] ml-[1px] text-[15px] font-semibold text-text-secondary hover:text-text-primary transition-colors"> 275 {serviceName} 276 </span> 277 <ChevronDown className={cn('w-3 h-3 ml-0.5 text-text-tertiary transition-transform', serviceMenuOpen && 'rotate-180')} /> 278 279 {/* Service dropdown */} 280 {serviceMenuOpen && ( 281 <div 282 className="absolute ltr:left-0 rtl:right-0 mt-2 w-64 bg-bg-elevated border border-border-subtle rounded-lg shadow-lg py-2" 283 style={{ zIndex: 9999 }} 284 > 285 {ecosystemApps 286 .filter(app => app.name !== serviceName) 287 .map((app) => ( 288 <a 289 key={app.name} 290 href={app.href} 291 className="flex items-center justify-between px-4 py-2.5 hover:bg-bg-tertiary transition-colors" 292 onClick={() => setServiceMenuOpen(false)} 293 > 294 <div> 295 <div className="text-sm font-medium text-text-primary">{app.name}</div> 296 <div className="text-xs text-text-tertiary">{app.description}</div> 297 </div> 298 <ExternalLink className="w-4 h-4 text-text-tertiary flex-shrink-0" /> 299 </a> 300 ))} 301 </div> 302 )} 303 </div> 304 </div> 305 </div> 306 307 {/* Desktop Navigation */} 308 <div className="hidden md:flex items-center gap-6"> 309 {navigation.map((item) => ( 310 <Link 311 key={item.name} 312 href={item.href} 313 className={cn( 314 'text-sm font-medium transition-colors hover:text-text-primary', 315 currentPath === item.href ? 'text-text-primary' : 'text-text-secondary' 316 )} 317 > 318 {item.name} 319 {item.external && <ExternalLink className="inline w-3 h-3 ml-1" />} 320 </Link> 321 ))} 322 </div> 323 324 {/* Right section: Search, Connect Wallet, Theme toggle */} 325 <div className="flex items-center gap-2"> 326 {/* Search */} 327 {showSearch && ( 328 <button 329 onClick={onSearchClick} 330 className="p-2 text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors" 331 aria-label="Search" 332 > 333 <Search className="w-5 h-5" /> 334 </button> 335 )} 336 337 {/* Connect Wallet */} 338 {showConnectWallet && ( 339 <ConnectWallet 340 size="sm" 341 onClick={onConnectWallet} 342 label={walletConnected && walletAddress ? walletAddress : undefined} 343 /> 344 )} 345 346 {/* Theme toggle */} 347 <button 348 onClick={toggleTheme} 349 className="p-2 text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors" 350 aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'} 351 > 352 {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} 353 </button> 354 355 {/* Language switcher */} 356 <div className="relative" ref={langMenuRef}> 357 <button 358 onClick={() => setLangMenuOpen(!langMenuOpen)} 359 className="flex items-center gap-1.5 p-2 text-text-secondary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors" 360 aria-label="Change language" 361 > 362 <Globe className="w-5 h-5" /> 363 <span className="hidden sm:inline text-sm">{currentLang.label}</span> 364 <ChevronDown className={cn('w-3 h-3 transition-transform', langMenuOpen && 'rotate-180')} /> 365 </button> 366 367 {langMenuOpen && langMenuPos && ( 368 <div 369 className="fixed w-44 bg-bg-elevated border border-border-subtle rounded-lg shadow-lg py-1 overflow-hidden" 370 style={{ 371 zIndex: 9999, 372 top: langMenuPos.top, 373 left: langMenuPos.left, 374 maxHeight: `calc(100vh - ${langMenuPos.top + 16}px)`, 375 }} 376 > 377 <div className="overflow-y-auto max-h-[60vh]"> 378 {languages.map((lang) => ( 379 <button 380 key={lang.code} 381 onClick={() => { 382 onLanguageChange?.(lang.code); 383 setLangMenuOpen(false); 384 }} 385 className={cn( 386 'w-full flex items-center gap-2 px-3 py-1 text-left hover:bg-bg-tertiary transition-colors', 387 (lang.code === currentLanguage || lang.code === normalizedLang) 388 ? 'text-alpha-500 bg-bg-tertiary' 389 : 'text-text-primary' 390 )} 391 > 392 <span className="text-sm leading-none">{lang.label}</span> 393 <span className="text-xs leading-tight">{lang.name}</span> 394 </button> 395 ))} 396 </div> 397 </div> 398 )} 399 </div> 400 </div> 401 </div> 402 403 </nav> 404 405 {/* Mobile Navigation Overlay */} 406 {mobileMenuOpen && ( 407 <> 408 {/* Backdrop */} 409 <div 410 className="md:hidden fixed inset-0 bg-black/50 backdrop-blur-sm" 411 style={{ zIndex: 9998 }} 412 onClick={() => setMobileMenuOpen(false)} 413 aria-hidden="true" 414 /> 415 {/* Menu Panel */} 416 <div 417 className="md:hidden fixed top-16 left-0 right-0 bottom-0 bg-bg-primary overflow-y-auto" 418 style={{ zIndex: 9999 }} 419 > 420 <div className="px-4 py-4 space-y-1"> 421 {/* Page navigation */} 422 {navigation.map((item) => ( 423 <Link 424 key={item.name} 425 href={item.href} 426 onClick={() => setMobileMenuOpen(false)} 427 className={cn( 428 'block px-3 py-3 text-base font-medium rounded-lg', 429 currentPath === item.href 430 ? 'bg-bg-tertiary text-text-primary' 431 : 'text-text-secondary hover:bg-bg-secondary' 432 )} 433 > 434 {item.name} 435 {item.external && <ExternalLink className="inline w-4 h-4 ml-2" />} 436 </Link> 437 ))} 438 439 {/* Ecosystem apps */} 440 <div className="pt-4 mt-4 border-t border-border-subtle"> 441 <div className="px-3 py-2 text-xs font-medium text-text-tertiary uppercase tracking-wide"> 442 {mobileMenuEcosystemLabel} 443 </div> 444 {ecosystemApps 445 .filter(app => app.name !== serviceName) 446 .map((app) => ( 447 <a 448 key={app.name} 449 href={app.href} 450 className="flex items-center justify-between px-3 py-3 text-base text-text-secondary hover:bg-bg-secondary rounded-lg" 451 onClick={() => setMobileMenuOpen(false)} 452 > 453 {app.name} 454 <ExternalLink className="w-4 h-4" /> 455 </a> 456 ))} 457 </div> 458 </div> 459 </div> 460 </> 461 )} 462 </header> 463 ); 464 } 465 ); 466 467 AppHeader.displayName = 'AppHeader'; 468 469 export { AppHeader, defaultEcosystemApps, languages };