Files
bootnode/console/components/navbar.tsx
zeekayandHanzo Dev 88397558f2 refactor(console): the account cluster is the shared chrome, and identity has one table
Two duplications, both removed.

1. The header's account cluster was an email in a <span> and a Sign out button.
   It is now <OrgSwitcher/> + <UserMenu/> from @luxfi/ui — the same controls the
   MPC dashboard and lux.market render, reading the same IAM verbs
   (get-account, get-organizations, get-organization-projects), with the same
   phone sheet, which this bar had no equivalent of at all. Signed out they
   offer sign-in rather than showing an empty list that reads as an account with
   no organizations.

2. lib/brand.ts held a SECOND host→brand table. It kept what is genuinely its
   own — logos, taglines, palettes, footer links, the ecosystem launcher, the
   API origins that answer — and now takes issuer, client id and IAM host from
   @luxfi/ui/white-label, which is the same resolver every Lux surface already
   uses. Two copies of one fact is two things to get wrong, and these two DID
   disagree: this table had Zoo on zoolabs.id (right) and the shared one on
   id.zoo.network (an alias whose own discovery document names zoolabs.id, so a
   client on it rejects every token). The shared table is fixed; this file no
   longer has an opinion to be wrong about.

The bundler wiring for the gui engine comes from `withLuxUi` — the one place it
lives, rather than a hand-rolled transpile list per app.

`next build` passes (static export, every route emitted). `pnpm test` 26/26,
including the assertion that lux.cloud + lux-cloud + https://lux.cloud/auth/callback
— the one login that works today — is unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:59:30 -07:00

513 lines
22 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
ArrowRight,
Database,
Globe,
KeyRound,
Layers,
Menu,
Search,
Wallet,
Webhook,
X,
Shield,
Building2,
Zap,
Code2,
Blocks,
BarChart3,
Server,
Bot,
Lock,
Cpu,
Network,
Coins,
FileText,
BookOpen,
GraduationCap,
GitBranch,
MessageCircle,
LifeBuoy,
Activity,
} from "@luxfi/ui/icons"
import { OrgSwitcher, UserMenu } from "@luxfi/ui"
import { BrandLogo } from "@/components/brand-logo"
import { useBrand } from "@/components/brand-provider"
import { CommandMenu } from "@/components/command-menu"
import { NavMenu } from "@/components/nav-menu"
import { useAuth } from "@/lib/auth"
import { getSdkPackage } from "@/lib/sdk-package"
// Reusable icon link for mega-menu items
function IconLink({
href,
icon: Icon,
label,
description,
closeMenu,
}: {
href: string
icon: React.ElementType
label: string
description?: string
closeMenu: () => void
}) {
return (
<Link href={href} onClick={closeMenu} className="block">
<div className="group flex items-start gap-2 py-1">
<Icon className="h-3.5 w-3.5 mt-0.5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
<div>
<span className="text-sm text-foreground/80 group-hover:text-foreground transition-colors">
{label}
</span>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
</div>
</Link>
)
}
// Products mega-menu content
function ProductsContent({ closeMenu }: { closeMenu: () => void }) {
const brand = useBrand()
const { pkg: sdkPackage } = getSdkPackage()
return (
<div className="w-full">
{/* Featured hero cards */}
<div className="mb-4 pb-4 border-b border-border">
<div className="grid grid-cols-3 gap-3">
<Link
href="/products/node"
onClick={closeMenu}
className="group flex items-start gap-3 p-3 rounded-xl bg-gradient-to-br from-blue-500/5 to-cyan-500/5 border border-border hover:border-blue-500/30 transition-all"
>
<div className="w-9 h-9 rounded-lg bg-blue-500/10 flex items-center justify-center flex-shrink-0">
<Globe className="h-5 w-5 text-blue-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
Node (RPC)
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</div>
<p className="text-xs text-muted-foreground leading-snug mt-0.5">
Multi-chain JSON-RPC with 99.999% uptime
</p>
</div>
</Link>
<Link
href="/products/explorer"
onClick={closeMenu}
className="group flex items-start gap-3 p-3 rounded-xl bg-gradient-to-br from-emerald-500/5 to-teal-500/5 border border-border hover:border-emerald-500/30 transition-all"
>
<div className="w-9 h-9 rounded-lg bg-emerald-500/10 flex items-center justify-center flex-shrink-0">
<Search className="h-5 w-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
Explorer
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</div>
<p className="text-xs text-muted-foreground leading-snug mt-0.5">
Deploy your own branded block explorer
</p>
</div>
</Link>
<Link
href="/products/mpc"
onClick={closeMenu}
className="group flex items-start gap-3 p-3 rounded-xl bg-gradient-to-br from-violet-500/5 to-purple-500/5 border border-border hover:border-violet-500/30 transition-all"
>
<div className="w-9 h-9 rounded-lg bg-violet-500/10 flex items-center justify-center flex-shrink-0">
<KeyRound className="h-5 w-5 text-violet-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">
MPC
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
</div>
<p className="text-xs text-muted-foreground leading-snug mt-0.5">
Threshold key management, 15+ chains
</p>
</div>
</Link>
</div>
</div>
{/* Category grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-x-6 gap-y-4">
{/* RPC & Nodes */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
RPC & Nodes
</h3>
<div className="space-y-0.5">
<IconLink href="/products/node" icon={Globe} label="Multi-Chain RPC" closeMenu={closeMenu} />
<IconLink href="/chains/ethereum" icon={Cpu} label="Ethereum" closeMenu={closeMenu} />
<IconLink href="/chains/solana" icon={Cpu} label="Solana" closeMenu={closeMenu} />
<IconLink href="/chains/base" icon={Cpu} label="Base" closeMenu={closeMenu} />
<IconLink href="/chains" icon={ArrowRight} label="All 100+ Chains" closeMenu={closeMenu} />
</div>
</div>
{/* Data APIs */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
Data APIs
</h3>
<div className="space-y-0.5">
<IconLink href="/products/data" icon={Database} label="Token API" closeMenu={closeMenu} />
<IconLink href="/products/data" icon={Blocks} label="NFT API" closeMenu={closeMenu} />
<IconLink href="/products/data" icon={Code2} label="Transfers API" closeMenu={closeMenu} />
<IconLink href="/products/webhooks" icon={Webhook} label="Webhooks" closeMenu={closeMenu} />
</div>
</div>
{/* Wallets & AA */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
Wallets & AA
</h3>
<div className="space-y-0.5">
<IconLink href="/products/wallets" icon={Wallet} label="Smart Wallets" closeMenu={closeMenu} />
<IconLink href="/products/wallets" icon={Zap} label="Gas Sponsorship" closeMenu={closeMenu} />
<IconLink href="/products/wallets" icon={Lock} label="Session Keys" closeMenu={closeMenu} />
</div>
</div>
{/* Infrastructure */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
Infrastructure
</h3>
<div className="space-y-0.5">
<IconLink href="/products/rollups" icon={Layers} label="Custom Rollups" closeMenu={closeMenu} />
<IconLink href="/products/explorer" icon={Search} label="Block Explorer" closeMenu={closeMenu} />
<IconLink href="/products/mpc" icon={KeyRound} label="MPC Network" closeMenu={closeMenu} />
<IconLink href="/products/node" icon={Server} label="Archive Nodes" closeMenu={closeMenu} />
</div>
</div>
{/* Security & Keys */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
Security
</h3>
<div className="space-y-0.5">
<IconLink href="/products/mpc" icon={Shield} label="CGGMP21 (ECDSA)" closeMenu={closeMenu} />
<IconLink href="/products/mpc" icon={Shield} label="FROST (EdDSA)" closeMenu={closeMenu} />
<IconLink href="/products/mpc" icon={KeyRound} label="Key Resharing" closeMenu={closeMenu} />
<IconLink href="/products/mpc" icon={Network} label="Threshold Signing" closeMenu={closeMenu} />
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-md px-2.5 py-1 font-mono text-[10px] bg-secondary text-foreground">
{`npm i ${sdkPackage}`}
</div>
<span className="text-[10px] text-muted-foreground">
Install {brand.name} SDK
</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-3 bg-transparent border-border text-foreground/80 hover:bg-accent hover:text-foreground"
asChild
>
<Link href="/docs" onClick={closeMenu}>
Docs
</Link>
</Button>
<Button
size="sm"
className="bg-primary text-primary-foreground hover:bg-primary/90 h-7 text-xs px-3"
asChild
>
<Link href="/dashboard" onClick={closeMenu}>
All Products
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
</div>
</div>
</div>
)
}
// Solutions mega-menu content
function SolutionsContent({ closeMenu }: { closeMenu: () => void }) {
return (
<div className="w-full flex gap-6">
{/* Left: featured panels */}
<div className="w-64 flex-shrink-0 space-y-3">
<Link
href="/contact"
onClick={closeMenu}
className="block p-4 rounded-xl bg-gradient-to-br from-blue-500/5 to-cyan-500/5 border border-border/50 hover:border-border transition-all group"
>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg bg-foreground/10 flex items-center justify-center">
<Building2 className="h-4 w-4 text-foreground" />
</div>
<h3 className="text-foreground font-semibold text-sm">Enterprise</h3>
</div>
<p className="text-muted-foreground text-xs leading-relaxed mb-2">
Dedicated RPC, custom rollups, managed MPC clusters, and SLAs for
scale.
</p>
<span className="text-xs font-medium text-foreground/70 group-hover:text-foreground transition-colors inline-flex items-center gap-1">
Contact sales <ArrowRight className="h-3 w-3" />
</span>
</Link>
<Link
href="/pricing"
onClick={closeMenu}
className="block p-4 rounded-xl bg-gradient-to-br from-violet-500/5 to-purple-500/5 border border-border/50 hover:border-border transition-all group"
>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-lg bg-foreground/10 flex items-center justify-center">
<Shield className="h-4 w-4 text-foreground" />
</div>
<h3 className="text-foreground font-semibold text-sm">
Startup Program
</h3>
</div>
<p className="text-muted-foreground text-xs leading-relaxed mb-2">
Free tier with 100M compute units/mo. Scale as you grow.
</p>
<span className="text-xs font-medium text-foreground/70 group-hover:text-foreground transition-colors inline-flex items-center gap-1">
Get started <ArrowRight className="h-3 w-3" />
</span>
</Link>
</div>
{/* Right: columns */}
<div className="flex-1 grid grid-cols-3 gap-x-6 gap-y-4">
{/* By Use Case */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
Use Cases
</h3>
<div className="space-y-0.5">
<IconLink href="/products/node" icon={Globe} label="DApp Backend" description="RPC + Data APIs for any dApp" closeMenu={closeMenu} />
<IconLink href="/products/wallets" icon={Wallet} label="User Onboarding" description="Smart wallets, no seed phrase" closeMenu={closeMenu} />
<IconLink href="/products/rollups" icon={Layers} label="Launch a Chain" description="Custom L2 with your gas token" closeMenu={closeMenu} />
<IconLink href="/products/mpc" icon={KeyRound} label="Treasury Security" description="MPC key management for funds" closeMenu={closeMenu} />
<IconLink href="/products/explorer" icon={Search} label="Chain Transparency" description="Branded explorer for your network" closeMenu={closeMenu} />
</div>
</div>
{/* By Role */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
By Role
</h3>
<div className="space-y-0.5">
<IconLink href="/docs/quickstart" icon={Code2} label="Developers" description="SDKs, APIs, quickstarts" closeMenu={closeMenu} />
<IconLink href="/products/rollups" icon={Building2} label="Foundations" description="Launch and manage L2s" closeMenu={closeMenu} />
<IconLink href="/products/mpc" icon={Shield} label="Security Teams" description="MPC, audit trails, compliance" closeMenu={closeMenu} />
<IconLink href="/products/data" icon={BarChart3} label="Data Teams" description="Indexed chain data + analytics" closeMenu={closeMenu} />
</div>
</div>
{/* By Chain */}
<div className="space-y-1.5">
<h3 className="text-[10px] font-semibold uppercase tracking-wider mb-2 text-muted-foreground">
By Chain
</h3>
<div className="space-y-0.5">
<IconLink href="/chains/ethereum" icon={Coins} label="Ethereum" closeMenu={closeMenu} />
<IconLink href="/chains/solana" icon={Coins} label="Solana" closeMenu={closeMenu} />
<IconLink href="/chains/base" icon={Coins} label="Base" closeMenu={closeMenu} />
<IconLink href="/chains/arbitrum" icon={Coins} label="Arbitrum" closeMenu={closeMenu} />
<IconLink href="/chains/polygon" icon={Coins} label="Polygon" closeMenu={closeMenu} />
<IconLink href="/chains" icon={ArrowRight} label="All 100+ Chains" closeMenu={closeMenu} />
</div>
</div>
</div>
</div>
)
}
// Rows that appear in BOTH the desktop bar and the phone sheet. Declared once —
// the previous header wrote them twice and the two copies had already drifted
// (Chains was in the sheet only).
const PRIMARY_LINKS = [
{ name: "Fleet", href: "/fleet" },
{ name: "Chains", href: "/chains" },
{ name: "Docs", href: "/docs" },
{ name: "Pricing", href: "/pricing" },
]
// The product pages, for the phone sheet — which has no room for the desktop
// mega-menu. Every href here is a route the build actually emits. Note there
// is deliberately no bare `/products`: it is a DIRECTORY of pages with no
// index, so linking to it serves the SPA fallback (i.e. the home page) under a
// /products URL.
const PRODUCTS = [
{ name: "Node (RPC)", href: "/products/node" },
{ name: "Data APIs", href: "/products/data" },
{ name: "Smart Wallets", href: "/products/wallets" },
{ name: "Explorer", href: "/products/explorer" },
{ name: "MPC", href: "/products/mpc" },
{ name: "Rollups", href: "/products/rollups" },
{ name: "Webhooks", href: "/products/webhooks" },
]
/**
* The site header, in lux.cloud's chrome: a 57px sticky bar over a blurred
* translucent ground, one hairline rule beneath, the brand lockup at the left
* with its retracting wordmark, and the action cluster hard right.
*
* Below `lg` the nav and the actions collapse together behind one hamburger
* into a full-width sheet, so a 390px viewport still fits the bar and every
* action — Sign In included — stays reachable. The sheet closes on outside
* click and on Escape, matching every other overlay in the app.
*/
export function Navbar() {
const [isOpen, setIsOpen] = useState(false)
const brand = useBrand()
const { user, logout } = useAuth()
const ref = useRef<HTMLElement>(null)
const close = () => setIsOpen(false)
useEffect(() => {
if (!isOpen) return
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setIsOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsOpen(false)
}
document.addEventListener("click", onClick)
document.addEventListener("keydown", onKey)
return () => {
document.removeEventListener("click", onClick)
document.removeEventListener("keydown", onKey)
}
}, [isOpen])
return (
<header
ref={ref}
className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-xl"
>
<div className="mx-auto flex h-(--header-h) max-w-[1440px] items-center gap-8 px-6 max-lg:gap-0 max-sm:px-4">
<Link href="/" className="shrink-0" onClick={close}>
<BrandLogo collapsible />
</Link>
<Button
variant="outline"
size="icon"
aria-label="Toggle navigation menu"
aria-expanded={isOpen}
className="ml-auto lg:hidden"
onClick={(e) => {
e.stopPropagation()
setIsOpen((v) => !v)
}}
>
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{/* Desktop: an inline row. Phone: an opaque full-width sheet under the
bar. It must NOT carry its own backdrop-filter — nesting one inside
the header's re-roots the backdrop and the page bleeds through the
menu, which is the bug lux.cloud documents in its own styles. */}
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-8",
"max-lg:absolute max-lg:inset-x-0 max-lg:top-full max-lg:max-h-[calc(100dvh-var(--header-h))] max-lg:flex-col max-lg:items-stretch max-lg:gap-4 max-lg:overflow-y-auto max-lg:overscroll-contain max-lg:border-b max-lg:border-border max-lg:bg-background max-lg:px-6 max-lg:pb-6 max-lg:pt-4",
isOpen ? "max-lg:flex" : "max-lg:hidden"
)}
>
<nav className="flex flex-1 items-center gap-6 max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5">
<span className="max-lg:hidden">
<NavMenu label="Products">{(closeMenu) => <ProductsContent closeMenu={closeMenu} />}</NavMenu>
</span>
<span className="max-lg:hidden">
<NavMenu label="Solutions">{(closeMenu) => <SolutionsContent closeMenu={closeMenu} />}</NavMenu>
</span>
{/* The sheet has no room for a mega-menu, so the products are
listed instead of collapsing to one link — the mega-menu is the
only route to them and losing it on a phone loses the product. */}
<span className="hidden w-full max-lg:block">
<span className="mb-1 mt-1 block text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
Products
</span>
{PRODUCTS.map((p) => (
<Link
key={p.href}
href={p.href}
onClick={close}
className="flex min-h-10 w-full items-center text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{p.name}
</Link>
))}
</span>
{PRIMARY_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={close}
className="text-sm text-muted-foreground transition-colors hover:text-foreground max-lg:flex max-lg:min-h-10 max-lg:w-full max-lg:items-center"
>
{link.name}
</Link>
))}
<a
href={brand.networkUrl}
className="text-sm text-muted-foreground transition-colors hover:text-foreground max-lg:flex max-lg:min-h-10 max-lg:w-full max-lg:items-center"
>
{brand.networkName}
</a>
</nav>
{/* In the sheet every action is a full-width 40px row, matching the
nav links above it — a 32px `sm` button is a fine desktop target
and a poor thumb target. */}
<div className="flex items-center gap-2.5 max-lg:flex-col max-lg:items-stretch max-lg:gap-2 [&_button]:max-lg:h-10 [&_a]:max-lg:h-10">
<CommandMenu />
{/* The account cluster is the SHARED chrome (@luxfi/ui), not a
third copy of it. What was here was an email in a <span> and a
Sign out button — no menu, no way to see or change which org you
were acting in, and a "sign in" that only ever named the brand.
The switchers below read IAM (get-account, get-organizations)
and are the same controls the MPC dashboard and lux.market show,
including the phone sheet, which this bar had no equivalent of.
They degrade honestly: signed out they offer sign-in rather than
rendering an empty list that reads as an account with no orgs. */}
<OrgSwitcher />
<UserMenu />
<Button size="sm" asChild onClick={close}>
<Link href="/dashboard">Dashboard</Link>
</Button>
</div>
</div>
</div>
</header>
)
}