refactor(market): one chrome — a wallet is not an account

lux.market had a header of its own: its own sticky bar, its own link styling, a
nav with no phone presentation at all, and a Connect button standing in for an
account menu the surface did not have. Two identities meet here and they are not
the same thing — the WALLET signs a listing, the ACCOUNT is who you are and
which org you act in — and only the first existed.

The chrome is now `AppNav` from @luxfi/ui: brand, org switcher, links, settings
and the user menu, identical to the MPC dashboard and the bootnode console, with
the links collapsing into the same gui Sheet the switchers use. The chain
selector and the wallet button stay, in the nav's free slot, because those are
this surface's own controls and not chrome.

Identity comes from IdentityProvider (IAM); brand, theme, issuer and IAM client
come from the Host header, so lux.market and a zoo-branded market are one image.
The gui engine's bundler wiring comes from `withLuxUi`, not a hand-rolled
transpile list.

`next build` passes: 6 static routes exported.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 15:00:04 -07:00
co-authored by Hanzo Dev
parent 9e56f246d0
commit e5c807bddc
6 changed files with 1868 additions and 1751 deletions
+7 -1
View File
@@ -1,6 +1,12 @@
import path from 'path'
import type { NextConfig } from 'next'
import { withLuxUi } from '@luxfi/ui/next'
// `withLuxUi` is the ONE place the gui engine's bundler wiring lives — transpile
// list, react-native → react-native-web alias in both bundlers, platform
// defines. Every Next surface needed the identical incantation; the ones that
// hand-rolled it each transpiled a different subset, which is how one app
// renders a Sheet and the next throws on the same component.
const nextConfig: NextConfig = {
output: 'export',
outputFileTracingRoot: path.resolve(import.meta.dirname ?? __dirname),
@@ -24,4 +30,4 @@ const nextConfig: NextConfig = {
},
}
export default nextConfig
export default withLuxUi(nextConfig as never) as NextConfig
+9 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@luxfi/market",
"version": "1.0.1",
"version": "1.0.2",
"description": "Lux NFT Marketplace - Genesis NFTs, trading, and NFT AMM",
"private": true,
"license": "GPL-3.0-or-later",
@@ -12,7 +12,9 @@
"lint": "next lint"
},
"dependencies": {
"@luxfi/ui": "7.4.0",
"@hanzo/gui": "^7.3.0",
"@hanzo/iam": "^0.13.8",
"@luxfi/ui": "7.4.3",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -22,8 +24,9 @@
"lucide-react": "^1.27.0",
"next": "15.5.0",
"next-themes": "^0.4.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-native-web": "^0.21.0",
"tailwind-merge": "^2.3.0",
"viem": "2.43.3",
"wagmi": "2.15.5"
@@ -31,8 +34,8 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "22.13.1",
"@types/react": "19.1.5",
"@types/react-dom": "19.1.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "4.1.18",
"typescript": "5.8.3"
},
+1683 -1539
View File
File diff suppressed because it is too large Load Diff
+54 -105
View File
@@ -1,111 +1,60 @@
/*
* lux.market rides the canonical Lux token set.
*
* What it replaced: a hand-rolled shadcn block whose neutrals all carried hue
* 240 at up to 10% saturation (--foreground 240 10% 3.9%, --muted 240 3.7%
* 15.9%, --ring 240 4.9% 83.9%) — the same cool cast the MPC dashboard had, and
* nothing like explore or the bootnode console next to it — plus a literal tan
* in BOTH modes: --chart-4 and --warning at hsl(38.3 95.8% 56.7%) = #F9B03D.
*
* Tokens are hex now, so `hsl(var(--x))` became `var(--x)` and
* `hsl(var(--x) / 0.8)` became a color-mix().
*/
@import "tailwindcss";
@import "@luxfi/ui/tokens.css";
:root {
/* Radius is the one non-colour value this app owns. */
--radius: 0.75rem;
}
/* Default to dark mode */
html {
color-scheme: dark;
}
/* CSS Variables - Light theme */
:root {
--radius: 0.75rem;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--chart-1: 142.1 76.2% 36.3%;
--chart-2: 217.2 91.2% 59.8%;
--chart-3: 316.6 73.3% 69.4%;
--chart-4: 38.3 95.8% 56.7%;
--chart-5: 0 84.2% 60.2%;
--success: 142.1 76.2% 36.3%;
--success-foreground: 0 0% 98%;
--warning: 38.3 95.8% 56.7%;
--warning-foreground: 0 0% 0%;
--info: 217.2 91.2% 59.8%;
--info-foreground: 0 0% 98%;
}
/* Dark theme - Primary for Lux apps */
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--chart-1: 142.1 70.6% 45.3%;
--chart-2: 217.2 91.2% 59.8%;
--chart-3: 316.6 73.3% 52%;
--chart-4: 38.3 95.8% 56.7%;
--chart-5: 0 84.2% 60.2%;
--success: 142.1 70.6% 45.3%;
--success-foreground: 0 0% 98%;
--warning: 38.3 95.8% 56.7%;
--warning-foreground: 0 0% 0%;
--info: 217.2 91.2% 59.8%;
--info-foreground: 0 0% 98%;
}
@theme inline {
--radius: 0.75rem;
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-info: hsl(var(--info));
--color-info-foreground: hsl(var(--info-foreground));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
--font-mono: var(--font-mono, ui-monospace, monospace);
@@ -124,7 +73,7 @@ html {
}
* {
border-color: hsl(var(--border));
border-color: var(--border);
}
html {
@@ -133,13 +82,13 @@ html {
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
background-color: var(--background);
color: var(--foreground);
font-feature-settings: "rlig" 1, "calt" 1;
}
.glass {
background-color: hsl(var(--background) / 0.8);
background-color: color-mix(in srgb, var(--background) 80%, transparent);
backdrop-filter: blur(24px);
}
@@ -174,5 +123,5 @@ body {
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: hsl(var(--muted)); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
::-webkit-scrollbar-thumb { background: var(--muted); border-radius: 9999px; }
::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--muted-foreground) 50%, transparent); }
+46 -9
View File
@@ -1,12 +1,26 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
import { configureIam, logout, startLogin } from '@hanzo/iam/browser'
import { AppProvider, IdentityProvider } from '@luxfi/ui'
import { resolveWhiteLabel } from '@luxfi/ui/white-label'
import { QueryClient } from '@tanstack/react-query'
import { useEffect, useState, type ReactNode } from 'react'
import { WagmiProvider, createConfig, http } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { ChainContext } from '@/hooks/useChain'
import { supportedChains } from '@/lib/chains'
// Two identities meet on this surface and they are NOT the same thing:
//
// the WALLET — wagmi, an address, what signs a listing;
// the ACCOUNT — IAM, who you are, which org you act in.
//
// The market had only the first, so its header was a Connect button and there
// was no way to tell lux.market from zoo.market except the copy. `AppProvider`
// carries the white-label (brand, gui theme, OIDC issuer, IAM client — all from
// the Host header) and `IdentityProvider` the account, exactly as every other
// Lux surface does. Wallet state stays where it was.
function createWagmiConfig() {
return createConfig({
chains: supportedChains,
@@ -16,20 +30,43 @@ function createWagmiConfig() {
})
}
// Local dev has no brand to resolve from `localhost` — name the host the surface
// stands in for. Unset in every deployment, where the real Host header answers.
const DEV_HOST = process.env.NEXT_PUBLIC_HOST
export function Providers({ children }: { children: ReactNode }) {
const [chainId, setChainId] = useState(96369)
const [config] = useState(() => createWagmiConfig())
// ONE query cache for the surface: AppProvider owns the provider, so wagmi
// and the chrome share a client instead of running two.
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 2, staleTime: 60_000 } },
}))
const [host, setHost] = useState<string | undefined>(undefined)
useEffect(() => {
const real = window.location.host
const local = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(real)
const resolved = local && DEV_HOST ? DEV_HOST : real
setHost(resolved)
const wl = resolveWhiteLabel(resolved)
configureIam({ issuer: wl.issuer, clientId: wl.clientId })
}, [])
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<ChainContext.Provider value={{ chainId, setChainId }}>
{children}
</ChainContext.Provider>
</QueryClientProvider>
</WagmiProvider>
<AppProvider host={host} queryClient={queryClient}>
<IdentityProvider
auth={{
signIn: () => void startLogin({ redirect: '/' }),
signOut: () => void logout(),
}}
>
<WagmiProvider config={config}>
<ChainContext.Provider value={{ chainId, setChainId }}>
{children}
</ChainContext.Provider>
</WagmiProvider>
</IdentityProvider>
</AppProvider>
)
}
+69 -91
View File
@@ -1,6 +1,6 @@
'use client'
import Link from 'next/link'
import { AppNav } from '@luxfi/ui'
import { usePathname } from 'next/navigation'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
@@ -11,6 +11,15 @@ import { Wallet, LogOut } from 'lucide-react'
import { cn } from '@/lib/utils'
import { shortenAddress } from '@/lib/utils'
// The chrome is `AppNav` from @luxfi/ui — brand, org switcher, links, settings
// and the user menu, identical to every other Lux surface. What used to be here
// was a second header: its own sticky bar, its own link styling, a `flex` nav
// with no phone presentation at all, and a wallet control standing in for an
// account menu it did not have.
//
// The chain selector and the wallet button stay, in the nav's free slot: they
// are this surface's own controls, not chrome. A wallet is not an account.
const NAV_LINKS = [
{ href: '/', label: 'Explore' },
{ href: '/collections', label: 'Collections' },
@@ -21,102 +30,71 @@ const NAV_LINKS = [
const CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
export function Header() {
const pathname = usePathname()
/** Which chain the marketplace reads — a market control, not chrome. */
function ChainSelector() {
const { chainId, setChainId } = useChainContext()
return (
<div className="hidden gap-0.5 rounded-lg bg-card p-0.5 sm:flex">
{CHAIN_IDS.map((id) => {
const info = CHAIN_INFO[id]
const isActive = chainId === id
return (
<button
key={id}
onClick={() => setChainId(id)}
title={info.name}
className={cn(
'cursor-pointer rounded-md border-none px-2.5 py-1.5 text-xs font-semibold transition-colors',
isActive ? 'text-black' : 'bg-transparent text-muted-foreground hover:text-foreground',
)}
style={isActive ? { background: info.color } : undefined}
>
{info.name}
</button>
)
})}
</div>
)
}
/** The wallet that signs listings — distinct from the IAM account above it. */
function WalletButton() {
const { address, isConnected } = useAccount()
const { connect } = useConnect()
const { disconnect } = useDisconnect()
if (!isConnected) {
return (
<Button variant="connect" size="sm" onClick={() => connect({ connector: injected() })}>
<Wallet className="h-4 w-4" />
Connect
</Button>
)
}
return (
<header className="sticky top-0 z-50 flex items-center justify-between px-6 py-3 border-b border-border bg-background/80 backdrop-blur-xl">
<div className="flex items-center gap-8">
<Link
href="/"
className="text-lg font-bold tracking-tight text-foreground no-underline"
>
Lux Market
</Link>
<nav className="flex gap-1">
{NAV_LINKS.map((link) => {
const isActive = link.href === '/' ? pathname === '/' : pathname.startsWith(link.href)
return (
<Link
key={link.href}
href={link.href}
className={cn(
'px-3 py-1.5 rounded-lg text-sm no-underline transition-colors',
isActive
? 'text-foreground bg-card'
: 'text-muted-foreground hover:text-foreground'
)}
>
{link.label}
</Link>
)
})}
</nav>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-muted-foreground">
{address ? shortenAddress(address) : ''}
</span>
<Button variant="ghost" size="icon" onClick={() => disconnect()} title="Disconnect wallet">
<LogOut className="h-4 w-4" />
</Button>
</div>
)
}
<div className="flex items-center gap-3">
{/* Chain selector */}
<div className="flex gap-0.5 bg-card rounded-lg p-0.5">
{CHAIN_IDS.map((id) => {
const info = CHAIN_INFO[id]
const isActive = chainId === id
return (
<button
key={id}
onClick={() => setChainId(id)}
title={info.name}
className={cn(
'px-2.5 py-1.5 text-xs font-semibold rounded-md cursor-pointer transition-colors border-none',
isActive
? 'text-black'
: 'text-muted-foreground bg-transparent hover:text-foreground'
)}
style={isActive ? { background: info.color } : undefined}
>
{info.name}
</button>
)
})}
</div>
<a
href="https://exchange.lux.network"
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 text-[13px] text-muted-foreground no-underline border border-border rounded-lg hover:text-foreground transition-colors"
>
Exchange
</a>
{isConnected ? (
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-muted-foreground">
{address ? shortenAddress(address) : ''}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => disconnect()}
title="Disconnect"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
) : (
<Button
variant="connect"
size="sm"
onClick={() => connect({ connector: injected() })}
>
<Wallet className="h-4 w-4" />
Connect
</Button>
)}
</div>
</header>
export function Header() {
const pathname = usePathname()
return (
<AppNav
brand="Lux Market"
links={NAV_LINKS.map((l) => ({
...l,
active: l.href === '/' ? pathname === '/' : pathname.startsWith(l.href),
}))}
>
<ChainSelector />
<WalletButton />
</AppNav>
)
}