Corrections per 2026-04 institutional review:
- MLFO reclassified as Global Family Office (was incorrectly labeled central bank)
- BIS Innovation Hub reclassified as Standards Body (does not hold observer seat)
- Added missing entities: ICCC, SAID, PANDA, Order of Hospitallers (XOM)
- Added BRICS founding + expanded member central banks (10 entries)
New institutional tier taxonomy (7 tiers):
sovereign_central_bank, global_family_office, settlement_member,
infrastructure_operator, oversight_judicial, delegated_authority,
standards_body
Backend changes:
- New auth/membership.go: tier types, DefaultTrackForTier mapping,
MembershipStore with DB queries for member directory
- New migration 0017: institutional_members + institutional_member_wallets
tables with seed data for all corrected members
- Updated wallet_auth.go getUserTrack(): now resolves institutional
membership (via wallet junction table) before defaulting to Track 1
- WalletAuthResponse now includes institutional_tier and institution_name
- New REST endpoints: GET /api/v1/membership/{tiers,members,members/:slug}
- Added TrackLabel() helper in featureflags
Frontend changes:
- Added InstitutionalTier type and label map to access.ts
- WalletAccessSession extended with institutionalTier/institutionName
- Navbar getAccessTier() now displays institutional tier label when present
- Session summary shows institution name
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
963 lines
42 KiB
TypeScript
963 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,
|
|
onConnect,
|
|
onCopyAddress,
|
|
onSwitchWallet,
|
|
onDisconnect,
|
|
}: {
|
|
walletSession: WalletAccessSession | null
|
|
connectingWallet: boolean
|
|
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: 'Settings',
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|
|
|
|
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 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)
|
|
await accessApi.connectWalletSession()
|
|
router.push('/access')
|
|
setMobileMenuOpen(false)
|
|
} catch (error) {
|
|
console.error('Wallet connect failed', error)
|
|
router.push('/access')
|
|
setMobileMenuOpen(false)
|
|
} 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 Monitoring', 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 Surface', description: 'Open planner, route, and relay shortcuts in one public page.' },
|
|
{ href: '/weth', label: 'WETH References', 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-[76px] items-center gap-4 lg:min-h-[84px]">
|
|
<Link
|
|
href="/"
|
|
className="group inline-flex min-w-0 items-center gap-3 rounded-2xl py-2 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 SolaceScan 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 lg:flex">
|
|
<SearchControl active={isSearchActive} onSelect={() => setCommandPaletteOpen(true)} />
|
|
<UiModeToggle />
|
|
<AccountButton
|
|
walletSession={walletSession}
|
|
connectingWallet={connectingWallet}
|
|
onConnect={() => void handleConnectWallet()}
|
|
onCopyAddress={() => void handleCopyAddress()}
|
|
onSwitchWallet={() => void handleSwitchWallet()}
|
|
onDisconnect={handleDisconnectWallet}
|
|
/>
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-2 lg: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 lg:hidden"
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<SearchControl
|
|
active={isSearchActive}
|
|
mobile
|
|
onSelect={() => {
|
|
setMobileMenuOpen(false)
|
|
setCommandPaletteOpen(true)
|
|
}}
|
|
/>
|
|
<UiModeToggle mobile />
|
|
|
|
<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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|