/ src / components / AppHeader / AppHeader.tsx
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 };