Files
explore/ui/snippets/topBar/ChainSwitcher.tsx
T
zeekay 608243e03a refactor(pchain): Subnet -> Net; add the mainnet explorer ingress
Drops "subnet" from the P-chain surface, which is not our vocabulary:

    useSubnets          -> useNets      (useSubnets.ts deleted)
    PChainSubnet        -> PChainNet
    GetSubnetsResponse  -> GetNetsResponse

One "subnet" deliberately survives — `subnetID` in lib/api/pchain/wire.spec.ts.
That is the UPSTREAM P-chain wire field, read through a raw
Record<string,string> cast, so renaming it would break response parsing. The
rule is about our own language, not about lying to the wire.

Also adds deploy/k8s/explore-fe/ingress-mainnet.yaml. explore.lux.network is
served by the Next.js deployment explore-fe-lux (ghcr.io/luxfi/explore), not
the Go `explorer` binary, which only backs api-explore.lux.network. This
replaces drift where the plain Ingress pointed explore.lux.network at
explorer:80 — the Go binary's embedded, stale build.

Two pre-commit gates had to be satisfied, both legitimate:
- eslint max-len: three className lines (BridgePage 230 chars, ChainRow 188 x2)
  exceeded the 160 limit; wrapped.
- cspell: `platformvm` flagged unknown. It is the P-chain VM's actual name, so
  it is added to cspell.jsonc `words` rather than renamed in code. (Note for
  the next person: .cspell-words.txt is listed under ignorePaths — it is a file
  excluded from checking, NOT the dictionary. Adding a word there does nothing.)

Verified: tsc --noEmit reports ZERO errors in every file this commit touches and
zero stale-symbol errors, so the rename resolves completely. The project's 223
pre-existing errors are all in untouched files and unchanged by this commit.
2026-07-26 09:11:38 -07:00

224 lines
8.9 KiB
TypeScript

import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from '@luxfi/ui/popover';
import React from 'react';
import { getCurrentChain, getCurrentNetwork, getChainsForNetwork, NETWORKS } from 'configs/app/chainRegistry';
import { cn } from 'lib/utils/cn';
import { Link } from 'toolkit/next/link';
// DropdownNavigator carries per-item state for the anchor's onClick so the
// handler reference stays stable across renders (satisfies react/jsx-no-bind)
// while still closing the popover and forcing navigation — the Popover
// framework otherwise swallows the anchor's default action in some browsers.
class DropdownNavigator {
constructor(
private readonly targetUrl: string | undefined,
private readonly isDisabled: boolean,
private readonly closePopover: () => void,
) {}
handle = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (this.isDisabled || !this.targetUrl) return;
e.preventDefault();
this.closePopover();
window.location.href = this.targetUrl;
};
}
const ChainSwitcher = () => {
const [ open, setOpen ] = React.useState(false);
const handleOpenChange = React.useCallback(({ open: isOpen }: { open: boolean }) => {
setOpen(isOpen);
}, []);
const closePopover = React.useCallback(() => {
setOpen(false);
}, []);
const current = getCurrentChain();
const currentNetwork = getCurrentNetwork();
const isMainChain = current.name === 'C-Chain';
// For white-label L1s (Zoo, Pars, Hanzo, SPC), only show that chain's
// mainnet/testnet entries — don't show the full Lux ecosystem.
// For the main C-Chain explorer, show all chains.
const chains = React.useMemo(() => {
const allChains = getChainsForNetwork(currentNetwork.network);
if (isMainChain) {
return allChains;
}
// Filter to only chains with the same branding (same network)
return allChains.filter((c) => c.branding.brandName === current.branding.brandName);
}, [ currentNetwork.network, isMainChain, current.branding.brandName ]);
// For L1s, show network switcher (mainnet/testnet) instead of chain switcher
const availableNetworks = React.useMemo(() => {
if (isMainChain) return [];
return NETWORKS.filter((net) => {
const chainsInNet = getChainsForNetwork(net.network);
return chainsInNet.some((c) => c.branding.brandName === current.branding.brandName);
});
}, [ isMainChain, current.branding.brandName ]);
return (
<PopoverRoot
positioning={{ placement: 'bottom-end', offset: { mainAxis: 8 } }}
lazyMount
open={ open }
onOpenChange={ handleOpenChange }
>
<PopoverTrigger>
<button
className={ cn(
'flex items-center gap-1.5 px-2 py-1 rounded-sm cursor-pointer',
'border border-[var(--color-border-divider)] text-xs font-medium',
'text-[var(--color-text-primary)] bg-transparent shrink-0',
'hover:bg-[var(--color-blackAlpha-50)]',
'dark:hover:bg-[var(--color-whiteAlpha-50)]',
'transition-all duration-150',
) }
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={ current.branding.logoViewBox }
className="w-3.5 h-3.5 shrink-0"
dangerouslySetInnerHTML={{ __html: current.branding.logoContent }}
/>
{ current.name }
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-3 h-3"
>
<path
fillRule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clipRule="evenodd"
/>
</svg>
</button>
</PopoverTrigger>
<PopoverContent w="240px">
<PopoverBody className="p-1">
{ /* Network switcher for L1s (e.g. Zoo Mainnet / Zoo Testnet) */ }
{ availableNetworks.length > 1 && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wider">
Network
</span>
</div>
{ availableNetworks.map((net) => {
const isCurrentNet = net.network === currentNetwork.network;
const chainInNet = getChainsForNetwork(net.network)
.find((c) => c.branding.brandName === current.branding.brandName);
const targetUrl = chainInNet?.explorerUrl;
const nav = new DropdownNavigator(targetUrl, isCurrentNet, closePopover);
return (
<a
key={ net.network }
href={ targetUrl || '#' }
onClick={ nav.handle }
className={ cn(
'flex items-center gap-2 px-2.5 py-2 rounded-sm',
'no-underline transition-[background] duration-150',
isCurrentNet ?
'cursor-default bg-[var(--color-whiteAlpha-50)]' :
'cursor-pointer hover:bg-[var(--color-whiteAlpha-50)]',
) }
>
<span className={ cn(
'text-sm text-[var(--color-text-primary)]',
isCurrentNet ? 'font-semibold' : 'font-normal',
) }>
{ net.name }
</span>
{ isCurrentNet && (
<span className="ml-auto text-[10px] text-[var(--color-text-secondary)]">
Current
</span>
) }
</a>
);
}) }
</>
) }
{ /* Chain switcher (only for main C-Chain explorer) */ }
{ chains.length > 1 && (
<>
<div className="px-2 py-1.5">
<span className="text-xs font-semibold text-[var(--color-text-secondary)] uppercase tracking-wider">
Switch Chain
</span>
</div>
{ chains.map((chain) => {
const isCurrent = chain.name === current.name &&
chain.network === current.network;
const nav = new DropdownNavigator(chain.explorerUrl, isCurrent, closePopover);
return (
<a
key={ `${ chain.network }-${ chain.name }` }
href={ chain.explorerUrl || '#' }
onClick={ nav.handle }
className={ cn(
'flex items-center justify-between px-2.5 py-2',
'rounded-sm no-underline transition-[background] duration-150',
isCurrent ?
'cursor-default bg-[var(--color-whiteAlpha-50)]' :
'cursor-pointer hover:bg-[var(--color-whiteAlpha-50)]',
) }
>
<div className="flex items-center gap-2 min-w-0">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={ chain.branding.logoViewBox }
className="w-4 h-4 shrink-0"
dangerouslySetInnerHTML={{
__html: chain.branding.logoContent,
}}
/>
<div className="flex flex-col min-w-0">
<span className={ cn(
'text-sm text-[var(--color-text-primary)] truncate',
isCurrent ? 'font-semibold' : 'font-normal',
) }>
{ chain.branding.brandName }
</span>
<span className="text-xs text-[var(--color-text-secondary)]">
{ chain.label }
</span>
</div>
</div>
<span className={ cn(
'bg-[var(--color-whiteAlpha-100)] text-[var(--color-text-secondary)]',
'rounded-sm px-1.5 py-0.5 text-[10px] font-mono shrink-0',
) }>
{ chain.vm }
</span>
</a>
);
}) }
</>
) }
{ /* Only show "View all chains" link on main explorer */ }
{ isMainChain && (
<div className="px-2 py-1.5 border-t border-[var(--color-border-divider)] mt-1">
<Link
href="/chains"
className="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
variant="plain"
>
View all chains
</Link>
</div>
) }
</PopoverBody>
</PopoverContent>
</PopoverRoot>
);
};
export default React.memo(ChainSwitcher);