Files
explorer-monorepo/frontend/src/components/common/Navbar.tsx
defiQUG 4fac5e4856
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 13s
Validate Explorer / frontend (push) Successful in 1m29s
Validate Explorer / smoke-e2e (push) Failing after 2m27s
Fix UX audit gaps: tablet nav, footer, wallet connect, legacy demotion.
Close the 1024–1279px nav dead zone, align ops/footer labels, split homepage quick links, route successful wallet connect to /wallet with inline errors, add WETH to ops sub-nav, and demote legacy SPA with noindex plus banner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 22:30:35 -07:00

981 lines
42 KiB
TypeScript

'use client'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'
import { accessApi, institutionalTierLabels, type WalletAccessSession } from '@/services/api/access'
import BrandLockup from './BrandLockup'
import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette'
import { useUiMode } from './UiModeContext'
type MenuItem = {
href?: string
label: string
description?: string
external?: boolean
onSelect?: () => void | Promise<void>
}
type MenuTone = 'default' | 'emphasis'
const desktopLinkBase =
'inline-flex items-center rounded-xl px-3 py-2 text-[15px] font-medium text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:text-gray-200 dark:focus-visible:ring-offset-gray-900'
const desktopLinkIdle =
'hover:bg-gray-100 hover:text-gray-950 dark:hover:bg-gray-800 dark:hover:text-white'
const desktopLinkActive =
'bg-gray-100 text-gray-950 ring-1 ring-gray-200 dark:bg-gray-800 dark:text-white dark:ring-gray-700'
function shortAddress(address: string) {
if (!address) return 'Wallet'
return `${address.slice(0, 6)}...${address.slice(-4)}`
}
function SearchIcon({ className = 'h-4 w-4' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.9} d="m21 21-4.35-4.35" />
<circle cx="11" cy="11" r="6.5" strokeWidth={1.9} />
</svg>
)
}
function ChevronIcon({ open, className = 'h-4 w-4' }: { open?: boolean; className?: string }) {
return (
<svg
className={`${className} transition-transform ${open ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
aria-hidden
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m5 7 5 6 5-6" />
</svg>
)
}
function MenuDropdown({
label,
items,
active = false,
tone = 'default',
align = 'left',
menuHeader,
}: {
label: ReactNode
items: MenuItem[]
active?: boolean
tone?: MenuTone
align?: 'left' | 'right'
menuHeader?: ReactNode
}) {
const { mode } = useUiMode()
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const itemRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
const menuId = useId()
useEffect(() => {
if (!open) return
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null
if (!target || !wrapperRef.current?.contains(target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('touchstart', handlePointerDown)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('touchstart', handlePointerDown)
}
}, [open])
const focusMenuItem = (index: number) => {
const item = itemRefs.current[index]
item?.focus()
}
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setOpen(true)
requestAnimationFrame(() => focusMenuItem(0))
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
requestAnimationFrame(() => focusMenuItem(items.length - 1))
}
if (event.key === 'Escape') {
setOpen(false)
}
}
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
const currentIndex = itemRefs.current.findIndex((node) => node === document.activeElement)
if (event.key === 'ArrowDown') {
event.preventDefault()
focusMenuItem((currentIndex + 1 + items.length) % items.length)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
focusMenuItem((currentIndex - 1 + items.length) % items.length)
}
if (event.key === 'Home') {
event.preventDefault()
focusMenuItem(0)
}
if (event.key === 'End') {
event.preventDefault()
focusMenuItem(items.length - 1)
}
if (event.key === 'Escape') {
event.preventDefault()
setOpen(false)
triggerRef.current?.focus()
}
if (event.key === 'Tab') {
setOpen(false)
}
}
return (
<div
ref={wrapperRef}
className="relative hidden lg:block"
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget as Node | null
if (!nextTarget || !wrapperRef.current?.contains(nextTarget)) {
setOpen(false)
}
}}
>
<button
ref={triggerRef}
type="button"
className={[
desktopLinkBase,
tone === 'emphasis' ? 'text-gray-900 dark:text-white' : '',
active || open ? desktopLinkActive : desktopLinkIdle,
].join(' ')}
aria-expanded={open}
aria-haspopup="menu"
aria-controls={menuId}
onClick={() => setOpen((value) => !value)}
onKeyDown={handleTriggerKeyDown}
>
<span>{label}</span>
<ChevronIcon open={open} />
</button>
{open ? (
<ul
id={menuId}
role="menu"
aria-label={typeof label === 'string' ? label : undefined}
onKeyDown={handleMenuKeyDown}
className={[
'absolute top-full z-50 mt-3 min-w-[280px] overflow-hidden rounded-2xl border border-gray-200 bg-white p-2 shadow-[0_18px_50px_rgba(15,23,42,0.16)] dark:border-gray-700 dark:bg-gray-900',
align === 'right' ? 'right-0' : 'left-0',
].join(' ')}
>
{menuHeader ? (
<li role="none" className="border-b border-gray-200 px-3 pb-3 pt-1 dark:border-gray-800">
{menuHeader}
</li>
) : null}
{items.map((item, index) => {
const itemClassName =
'flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-200 dark:hover:bg-gray-800 dark:hover:text-white'
const content = (
<>
<span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-xs font-semibold text-gray-500 dark:bg-gray-800 dark:text-gray-300">
{index + 1}
</span>
<span>
<span className="block font-semibold">{item.label}</span>
{mode === 'guided' && item.description ? (
<span className="mt-0.5 block text-xs leading-5 text-gray-500 dark:text-gray-400">
{item.description}
</span>
) : null}
</span>
</>
)
return (
<li key={`${label}-${item.label}`} role="none">
{item.href ? (
item.external ? (
<a
ref={(node) => {
itemRefs.current[index] = node
}}
href={item.href}
role="menuitem"
target="_blank"
rel="noopener noreferrer"
className={itemClassName}
onClick={() => setOpen(false)}
>
{content}
</a>
) : (
<Link
ref={(node) => {
itemRefs.current[index] = node
}}
href={item.href}
role="menuitem"
className={itemClassName}
onClick={() => setOpen(false)}
>
{content}
</Link>
)
) : (
<button
ref={(node) => {
itemRefs.current[index] = node
}}
type="button"
role="menuitem"
className={itemClassName}
onClick={async () => {
await item.onSelect?.()
setOpen(false)
}}
>
{content}
</button>
)}
</li>
)
})}
</ul>
) : null}
</div>
)
}
function SearchControl({
active,
mobile = false,
onSelect,
}: {
active: boolean
mobile?: boolean
onSelect?: () => void
}) {
const { mode } = useUiMode()
if (mobile) {
return (
<button
type="button"
onClick={onSelect}
aria-label="Open explorer search"
className="inline-flex items-center gap-3 rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<SearchIcon />
<span>{mode === 'guided' ? 'Search explorer' : 'Search'}</span>
<span className="ml-auto rounded-lg border border-gray-200 px-2 py-0.5 text-[11px] uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
/
</span>
</button>
)
}
return (
<button
type="button"
onClick={onSelect}
aria-label="Open explorer search"
className={[
'hidden lg:inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2.5 text-sm font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-900',
active
? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-500/30 dark:bg-primary-500/10 dark:text-primary-300'
: 'border-gray-200 bg-white text-gray-800 hover:border-primary-300 hover:text-primary-700 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300',
].join(' ')}
>
<SearchIcon />
<span>{mode === 'guided' ? 'Search explorer' : 'Search'}</span>
<span className="rounded-lg border border-gray-200 px-2 py-0.5 text-[11px] uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
/
</span>
</button>
)
}
function getAccessTier(walletSession: WalletAccessSession) {
if (walletSession.institutionalTier) {
return institutionalTierLabels[walletSession.institutionalTier] ?? walletSession.institutionalTier
}
const permissions = walletSession.permissions || []
if (permissions.some((permission) => permission.startsWith('operator.'))) {
return 'Operator Tier'
}
if (permissions.some((permission) => permission.startsWith('analytics.'))) {
return 'Analytics Tier'
}
if (permissions.some((permission) => permission.includes('enhanced') || permission.includes('full'))) {
return 'Enhanced Explorer Tier'
}
return 'Explorer Tier'
}
function getSessionSummary(walletSession: WalletAccessSession) {
const permissionCount = walletSession.permissions?.length || 0
const tierLabel = getAccessTier(walletSession)
const institutionSuffix = walletSession.institutionName ? ` (${walletSession.institutionName})` : ''
if (permissionCount > 0) {
return `${tierLabel}${institutionSuffix} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}`
}
return `${tierLabel}${institutionSuffix} · Explorer access active`
}
function UiModeToggle({ mobile = false }: { mobile?: boolean }) {
const { mode, toggleMode } = useUiMode()
const className = mobile
? 'inline-flex items-center gap-2 rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300'
: 'hidden lg:inline-flex items-center gap-2 rounded-2xl border border-gray-200 bg-white px-3.5 py-2.5 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300 dark:focus-visible:ring-offset-gray-900'
return (
<button
type="button"
onClick={toggleMode}
aria-label={mode === 'guided' ? 'Switch to Expert Mode' : 'Switch to Guided Mode'}
title={mode === 'guided' ? 'Switch to Expert Mode' : 'Switch to Guided Mode'}
className={className}
>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-primary-500" aria-hidden />
<span>{mode === 'guided' ? 'Guided' : 'Expert'}</span>
<span className="sr-only">
{mode === 'guided'
? 'Guided mode shows fuller helper text and more interpreted labels.'
: 'Expert mode reduces helper text and increases information density.'}
</span>
</button>
)
}
function AccountButton({
walletSession,
connectingWallet,
connectError,
onConnect,
onCopyAddress,
onSwitchWallet,
onDisconnect,
}: {
walletSession: WalletAccessSession | null
connectingWallet: boolean
connectError?: string | null
onConnect: () => void
onCopyAddress: () => void
onSwitchWallet: () => void
onDisconnect: () => void
}) {
const { mode } = useUiMode()
const accountItems: MenuItem[] = [
{
href: '/access',
label: 'Account',
description: 'Open API access, subscriptions, and account-linked explorer features.',
},
{
href: '/wallet',
label: 'Wallet tools',
description: 'Review network, token-list, and wallet configuration guidance.',
},
{
label: 'Copy address',
description: walletSession?.address || 'Copy the connected wallet address.',
onSelect: onCopyAddress,
},
{
label: 'Switch wallet',
description: 'Connect a different wallet without leaving the explorer.',
onSelect: onSwitchWallet,
},
{
label: 'Disconnect wallet',
description: 'End the current wallet connection on this device.',
onSelect: onDisconnect,
},
]
if (!walletSession) {
return (
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={onConnect}
className="inline-flex items-center gap-2 rounded-2xl bg-gray-950 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:focus-visible:ring-offset-gray-900"
>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" aria-hidden />
<span>{connectingWallet ? 'Connecting…' : 'Connect Wallet'}</span>
</button>
{connectError ? (
<p role="alert" className="max-w-xs text-right text-xs text-red-600 dark:text-red-400">
{connectError}
</p>
) : null}
</div>
)
}
const sessionSummary = getSessionSummary(walletSession)
const tierLabel = getAccessTier(walletSession)
const expiresLabel = walletSession.expiresAt
? new Date(walletSession.expiresAt).toLocaleString()
: 'Session expiry unavailable'
return (
<MenuDropdown
label={
<span className="inline-flex items-center gap-2">
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" aria-hidden />
<span>{shortAddress(walletSession.address)}</span>
</span>
}
items={accountItems}
align="right"
tone="emphasis"
active={false}
menuHeader={
<div className="min-w-[280px]">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-950 dark:text-white">Wallet connected</div>
<div className="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
{mode === 'guided' ? `${sessionSummary}. Access tier is derived from current explorer permissions.` : sessionSummary}
</div>
</div>
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" aria-hidden />
Active
</span>
</div>
<div className="mt-3 rounded-2xl bg-gray-50 px-3 py-2.5 text-xs leading-5 text-gray-600 dark:bg-gray-800/80 dark:text-gray-300">
<div className="font-medium text-gray-800 dark:text-gray-100">{walletSession.address}</div>
<div className="mt-1">Access tier: {tierLabel}</div>
<div className="mt-1">Session expires {expiresLabel}</div>
</div>
</div>
}
/>
)
}
export default function Navbar() {
const router = useRouter()
const { mode, setMode } = useUiMode()
const pathname = usePathname() ?? ''
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
const [connectingWallet, setConnectingWallet] = useState(false)
const [walletConnectError, setWalletConnectError] = useState<string | null>(null)
const mobilePanelId = useId()
const isExploreActive =
pathname === '/' ||
pathname.startsWith('/blocks') ||
pathname.startsWith('/transactions') ||
pathname.startsWith('/addresses')
const isDataActive =
pathname.startsWith('/tokens') ||
pathname.startsWith('/analytics') ||
pathname.startsWith('/pools') ||
pathname.startsWith('/watchlist')
const isOperationsActive =
pathname.startsWith('/bridge') ||
pathname.startsWith('/routes') ||
pathname.startsWith('/liquidity') ||
pathname.startsWith('/operations') ||
pathname.startsWith('/system') ||
pathname.startsWith('/operator') ||
pathname.startsWith('/weth')
const isDocsActive = pathname.startsWith('/docs')
const isSearchActive = pathname.startsWith('/search')
useEffect(() => {
const syncWalletSession = () => {
setWalletSession(accessApi.getStoredWalletSession())
}
syncWalletSession()
window.addEventListener('storage', syncWalletSession)
window.addEventListener('explorer-access-session-changed', syncWalletSession)
const handleShortcut = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const tag = target?.tagName?.toLowerCase()
const isEditable =
tag === 'input' ||
tag === 'textarea' ||
target?.isContentEditable
if (isEditable) return
if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) {
event.preventDefault()
setCommandPaletteOpen(true)
}
}
window.addEventListener('keydown', handleShortcut)
return () => {
window.removeEventListener('storage', syncWalletSession)
window.removeEventListener('explorer-access-session-changed', syncWalletSession)
window.removeEventListener('keydown', handleShortcut)
}
}, [router])
const handleConnectWallet = async () => {
try {
setConnectingWallet(true)
setWalletConnectError(null)
await accessApi.connectWalletSession()
setMobileMenuOpen(false)
router.push('/wallet')
} catch (error) {
console.error('Wallet connect failed', error)
const message = error instanceof Error ? error.message : 'Wallet connection failed.'
setWalletConnectError(message)
} finally {
setConnectingWallet(false)
}
}
const handleCopyAddress = async () => {
if (!walletSession?.address || typeof navigator === 'undefined' || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(walletSession.address)
} catch (error) {
console.error('Failed to copy wallet address', error)
}
}
const handleDisconnectWallet = () => {
accessApi.clearSession()
accessApi.clearWalletSession()
setMobileMenuOpen(false)
}
const handleCopyCurrentUrl = async () => {
if (typeof window === 'undefined' || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(window.location.href)
} catch (error) {
console.error('Failed to copy current URL', error)
}
}
const handleSwitchWallet = async () => {
await handleConnectWallet()
}
const exploreItems: MenuItem[] = useMemo(
() => [
{ href: '/', label: 'Overview', description: 'Return to the main explorer dashboard and network summary.' },
{ href: '/blocks', label: 'Blocks', description: 'Browse recent block production and block detail pages.' },
{ href: '/transactions', label: 'Transactions', description: 'Inspect indexed transactions and their linked entities.' },
{ href: '/addresses', label: 'Addresses', description: 'Open recent address activity and address detail pages.' },
],
[],
)
const dataItems: MenuItem[] = useMemo(
() => [
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
{ href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' },
],
[],
)
const operationsItems: MenuItem[] = useMemo(
() => [
{ href: '/operations', label: 'Operations hub', description: 'Open the consolidated operator surface for live support workflows.' },
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
],
[],
)
const commandItems: HeaderCommandItem[] = [
{
label: 'Copy current page link',
description: 'Copy the current explorer URL for operator handoff or support notes.',
section: 'Actions',
keywords: ['copy', 'link', 'share', 'url'],
onSelect: () => void handleCopyCurrentUrl(),
},
walletSession
? {
href: '/access',
label: 'Open connected account',
description: `Review ${getAccessTier(walletSession)} permissions, subscriptions, and access features.`,
section: 'Actions',
keywords: ['account', 'session', 'access', 'permissions'],
}
: {
label: connectingWallet ? 'Connecting wallet…' : 'Connect wallet now',
description: 'Start wallet connection and open the account access surface.',
section: 'Actions',
keywords: ['wallet', 'connect', 'account', 'signin'],
onSelect: () => void handleConnectWallet(),
},
mode === 'guided'
? {
label: 'Switch to Expert Mode',
description: 'Reduce helper text and keep the interface denser.',
section: 'Actions',
keywords: ['guided', 'expert', 'mode', 'density'],
onSelect: () => setMode('expert'),
}
: {
label: 'Switch to Guided Mode',
description: 'Restore fuller explanations, helper text, and more interpreted labels.',
section: 'Actions',
keywords: ['guided', 'expert', 'mode', 'density'],
onSelect: () => setMode('guided'),
},
walletSession
? {
label: 'Disconnect wallet',
description: 'End the current wallet connection on this device.',
section: 'Actions',
keywords: ['wallet', 'disconnect', 'sign out'],
onSelect: handleDisconnectWallet,
}
: {
href: '/wallet',
label: 'Open wallet tools',
description: 'Review supported networks, token lists, and wallet integration guidance.',
section: 'Actions',
keywords: ['wallet', 'settings', 'metamask', 'network'],
},
...exploreItems.map((item) => ({
href: item.href || '/',
label: item.label,
description: item.description,
section: 'Explore',
keywords: ['overview', 'browse'],
})),
...dataItems.map((item) => ({
href: item.href || '/',
label: item.label,
description: item.description,
section: 'Data',
keywords: ['tokens', 'analytics', 'pools', 'watchlist'],
})),
{
href: '/docs',
label: 'Docs',
description: 'Open the canonical explorer documentation surface.',
section: 'Docs',
keywords: ['documentation', 'guide', 'reference'],
},
...operationsItems
.filter((item) => !item.external)
.map((item) => ({
href: item.href || '/',
label: item.label,
description: item.description,
section: 'Operations',
keywords: ['ops', 'bridge', 'routes', 'liquidity', 'system'],
})),
{
href: '/wallet',
label: 'Wallet Tools',
description: 'Open network, token-list, and wallet integration tooling.',
section: 'Wallet',
keywords: ['wallet', 'tokens', 'catalog', 'metamask'],
},
{
href: '/access',
label: 'Account Access',
description: 'Open account-linked explorer access and subscription tools.',
section: 'Account',
keywords: ['access', 'permissions', 'session', 'account'],
},
]
return (
<>
<header className="sticky top-0 z-40 border-b border-gray-200/90 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/88 dark:border-gray-800 dark:bg-gray-950/92">
<div className="container mx-auto px-4">
<div className="flex min-h-[60px] items-center gap-3 lg:min-h-[64px]">
<Link
href="/"
className="group inline-flex min-w-0 items-center gap-2 rounded-lg py-1.5 pr-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-gray-950"
onClick={() => setMobileMenuOpen(false)}
aria-label="Go to DBIS Explorer home"
>
<BrandLockup />
</Link>
<nav aria-label="Primary navigation" className="ml-4 hidden flex-1 items-center gap-1 xl:flex">
<MenuDropdown label="Explore" items={exploreItems} active={isExploreActive} />
<MenuDropdown label="Data" items={dataItems} active={isDataActive} />
<Link
href="/docs"
className={[desktopLinkBase, isDocsActive ? desktopLinkActive : desktopLinkIdle].join(' ')}
>
Docs
</Link>
<div className="ml-2 border-l border-gray-200 pl-2 dark:border-gray-800">
<MenuDropdown label="Operations" items={operationsItems} active={isOperationsActive} />
</div>
</nav>
<div className="ml-auto hidden items-center gap-3 xl:flex">
<SearchControl active={isSearchActive} onSelect={() => setCommandPaletteOpen(true)} />
<UiModeToggle />
<AccountButton
walletSession={walletSession}
connectingWallet={connectingWallet}
connectError={walletConnectError}
onConnect={() => void handleConnectWallet()}
onCopyAddress={() => void handleCopyAddress()}
onSwitchWallet={() => void handleSwitchWallet()}
onDisconnect={handleDisconnectWallet}
/>
</div>
<div className="ml-auto flex items-center gap-2 xl:hidden">
{walletSession ? (
<Link
href="/access"
aria-label="Open connected account"
className="inline-flex max-w-[132px] items-center gap-2 rounded-2xl border border-gray-200 bg-white px-3 py-2.5 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" aria-hidden />
<span className="truncate">{shortAddress(walletSession.address)}</span>
</Link>
) : (
<button
type="button"
onClick={() => void handleConnectWallet()}
aria-label="Connect wallet"
className="inline-flex items-center gap-2 rounded-2xl bg-gray-950 px-3 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-black focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100"
>
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-emerald-400" aria-hidden />
<span>Connect</span>
</button>
)}
<button
type="button"
onClick={() => setCommandPaletteOpen(true)}
aria-label="Open explorer search"
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-gray-200 bg-white text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<SearchIcon />
</button>
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-gray-200 bg-white text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
aria-expanded={mobileMenuOpen}
aria-controls={mobilePanelId}
aria-label={mobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu'}
onClick={() => setMobileMenuOpen((value) => !value)}
>
{mobileMenuOpen ? (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.9} d="M6 6 18 18M6 18 18 6" />
</svg>
) : (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.9} d="M4 7h16M4 12h16M4 17h16" />
</svg>
)}
</button>
</div>
</div>
{mobileMenuOpen ? (
<div
id={mobilePanelId}
className="border-t border-gray-200 py-4 dark:border-gray-800 xl:hidden"
>
<div className="flex flex-col gap-4">
<SearchControl
active={isSearchActive}
mobile
onSelect={() => {
setMobileMenuOpen(false)
setCommandPaletteOpen(true)
}}
/>
<UiModeToggle mobile />
{walletConnectError ? (
<p role="alert" className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{walletConnectError}
</p>
) : null}
<div className="grid gap-4">
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Explore
</div>
<div className="grid gap-2">
{exploreItems.map((item) => (
<Link
key={item.label}
href={item.href || '/'}
onClick={() => setMobileMenuOpen(false)}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<span className="block font-semibold">{item.label}</span>
{item.description ? (
<span className="mt-1 block text-xs leading-5 text-gray-500 dark:text-gray-400">{item.description}</span>
) : null}
</Link>
))}
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Data & Docs
</div>
<div className="grid gap-2">
{dataItems.map((item) => (
<Link
key={item.label}
href={item.href || '/'}
onClick={() => setMobileMenuOpen(false)}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<span className="block font-semibold">{item.label}</span>
{item.description ? (
<span className="mt-1 block text-xs leading-5 text-gray-500 dark:text-gray-400">{item.description}</span>
) : null}
</Link>
))}
<Link
href="/docs"
onClick={() => setMobileMenuOpen(false)}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<span className="block font-semibold">Docs</span>
<span className="mt-1 block text-xs leading-5 text-gray-500 dark:text-gray-400">
Open the canonical explorer documentation surface.
</span>
</Link>
</div>
</div>
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
Operations
</div>
<div className="grid gap-2">
{operationsItems.map((item) =>
item.external ? (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileMenuOpen(false)}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<span className="block font-semibold">{item.label}</span>
{item.description ? (
<span className="mt-1 block text-xs leading-5 text-gray-500 dark:text-gray-400">{item.description}</span>
) : null}
</a>
) : (
<Link
key={item.label}
href={item.href || '/'}
onClick={() => setMobileMenuOpen(false)}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300"
>
<span className="block font-semibold">{item.label}</span>
{item.description ? (
<span className="mt-1 block text-xs leading-5 text-gray-500 dark:text-gray-400">{item.description}</span>
) : null}
</Link>
),
)}
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-4 dark:border-gray-800">
{walletSession ? (
<div className="grid gap-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
<div className="font-semibold text-gray-900 dark:text-white">{getAccessTier(walletSession)}</div>
{mode === 'guided' ? (
<div className="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
Connected wallet access stays aligned with the same account state shown in the desktop header.
</div>
) : null}
</div>
<Link
href="/access"
onClick={() => setMobileMenuOpen(false)}
className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-semibold text-white dark:bg-white dark:text-gray-950"
>
Account · {shortAddress(walletSession.address)}
</Link>
<button
type="button"
onClick={() => void handleCopyAddress()}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-left text-sm font-medium text-gray-800 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
>
Copy address
</button>
<button
type="button"
onClick={() => void handleSwitchWallet()}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-left text-sm font-medium text-gray-800 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
>
Switch wallet
</button>
<button
type="button"
onClick={handleDisconnectWallet}
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-left text-sm font-medium text-gray-800 shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
>
Disconnect wallet
</button>
</div>
) : (
<button
type="button"
onClick={() => void handleConnectWallet()}
className="w-full rounded-2xl bg-gray-950 px-4 py-3 text-sm font-semibold text-white dark:bg-white dark:text-gray-950"
>
{connectingWallet ? 'Connecting…' : 'Connect Wallet'}
</button>
)}
</div>
</div>
</div>
) : null}
</div>
</header>
<HeaderCommandPalette
open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
items={commandItems}
/>
</>
)
}