web: decomplect brand into runtime white-label config

Remove the hardcoded 5-brand catalog from web/lib/brand.ts; resolve the
active brand at runtime by hostname from an external catalog
(window.__BRAND_CATALOG__ / /config.json), mirroring the @hanzo/id SPA.
Ship one neutral built-in bootnode fallback. Add BrandProvider that loads
the per-deploy catalog client-side after hydration.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-06-17 00:10:05 -07:00
co-authored by Hanzo Dev
parent 344c12aeba
commit 95d9ac6fa9
7 changed files with 228 additions and 280 deletions
+23 -25
View File
@@ -5,23 +5,19 @@ import { ThemeProvider } from "@/components/theme-provider"
import { AuthProvider } from "@/lib/auth"
import { Providers } from "@/app/providers"
import { Toaster } from "@/components/ui/sonner"
import { BrandProvider } from "@/components/brand-provider"
import { getBrand } from "@/lib/brand"
import "@/app/globals.css"
// Brand baked at build time from NEXT_PUBLIC_BRAND/BRAND (default lux for this
// deployment). Static export: no request-time host detection. The client still
// auto-detects the brand from window.location.hostname (see lib/brand.ts), so
// runtime CSS/logos remain host-correct; only SSR metadata is baked here.
const brands: Record<string, { name: string; tagline: string; domain: string; icon: string; defaultTheme: string }> = {
bootnode: { name: "Bootnode", tagline: "Blockchain Infrastructure", domain: "bootno.de", icon: "/logo/bootnode-icon.svg", defaultTheme: "light" },
hanzo: { name: "Hanzo Web3", tagline: "Web3 Infrastructure", domain: "web3.hanzo.ai", icon: "/logo/hanzo-icon.svg", defaultTheme: "dark" },
lux: { name: "Lux Cloud", tagline: "Next-Gen Blockchain Infrastructure", domain: "cloud.lux.network", icon: "/logo/lux-icon.svg", defaultTheme: "dark" },
pars: { name: "Pars Cloud", tagline: "Sovereign L1 Infrastructure", domain: "cloud.pars.network", icon: "/logo/pars-icon.svg", defaultTheme: "dark" },
zoo: { name: "Zoo Labs", tagline: "Decentralized AI Infrastructure", domain: "web3.zoo.ngo", icon: "/logo/zoo-icon.svg", defaultTheme: "dark" },
}
// Static export: the prerendered HTML is brand-neutral. There is no
// request-time host detection here, and source carries no brand catalog — so
// SSG metadata uses bootnode's built-in neutral default. The real brand is
// resolved CLIENT-SIDE after hydration by BrandProvider, which loads the
// per-deploy catalog (window.__BRAND_CATALOG__ / /config.json) and re-resolves
// title/favicon/logos by hostname. Mirrors the @hanzo/id SPA.
function getServerBrand() {
const key = (process.env.BRAND || process.env.NEXT_PUBLIC_BRAND || "bootnode").toLowerCase()
return brands[key] || brands.bootnode
const b = getBrand()
return { name: b.name, tagline: b.tagline, domain: b.domain, icon: b.favicon, defaultTheme: b.defaultTheme }
}
export function generateMetadata(): Metadata {
@@ -69,17 +65,19 @@ export default function RootLayout({
>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme={b.defaultTheme}
enableSystem={false}
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</AuthProvider>
<BrandProvider>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme={b.defaultTheme}
enableSystem={false}
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</AuthProvider>
</BrandProvider>
</Providers>
</body>
</html>
+5 -12
View File
@@ -3,19 +3,12 @@
import Image from "next/image"
import { useEffect, useState } from "react"
import { useTheme } from "next-themes"
import { getBrand, type BrandConfig } from "@/lib/brand"
import { getBrand } from "@/lib/brand"
import { useBrand } from "@/components/brand-provider"
// Hook to get brand configuration with client-side detection
export function useBrand() {
const [brand, setBrand] = useState<BrandConfig>(getBrand())
useEffect(() => {
// Re-check brand on client side (for domain detection)
setBrand(getBrand())
}, [])
return brand
}
// Re-export the brand hook so existing importers (`@/components/brand-logo`)
// keep working. Single source of truth lives in brand-provider.tsx.
export { useBrand }
// Brand Logo Component - theme-aware and white-label ready
export function BrandLogo({
+57
View File
@@ -0,0 +1,57 @@
"use client"
// BrandProvider — loads the per-deploy brand catalog at runtime and exposes the
// resolved BrandConfig to the tree. Mirrors the @hanzo/id SPA's App-level load:
// a `useEffect` reads the injected `window.__BRAND_CATALOG__` global and/or
// fetches `/config.json`, applies it, then re-resolves the active brand by host.
//
// Static export note: the prerendered HTML is brand-neutral (built-in bootnode
// defaults). Brand applies CLIENT-SIDE after hydration once the runtime catalog
// is available — exactly the id SPA behaviour.
import { createContext, useContext, useEffect, useState } from "react"
import { applyBrandCatalog, getBrand, type BrandConfig } from "@/lib/brand"
const BrandContext = createContext<BrandConfig>(getBrand())
export function BrandProvider({ children }: { children: React.ReactNode }) {
// Start from the synchronous resolve (injected global if the deploy inlined
// it before hydration; else the neutral built-in default).
const [brand, setBrand] = useState<BrandConfig>(() => getBrand())
useEffect(() => {
let cancelled = false
// 1) Ingest any deploy-injected global synchronously (no flash if present).
const injected = (window as unknown as { __BRAND_CATALOG__?: unknown }).__BRAND_CATALOG__
if (injected) {
applyBrandCatalog(injected)
setBrand(getBrand())
}
// 2) Also fetch /config.json (the deploy-provisioned catalog) so the app
// works when the catalog is served as a file rather than inlined.
fetch("/config.json", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : null))
.then((cat) => {
if (cancelled || !cat) return
applyBrandCatalog(cat)
setBrand(getBrand())
})
.catch(() => {
/* no catalog provisioned — keep the built-in neutral default */
})
return () => {
cancelled = true
}
}, [])
return <BrandContext.Provider value={brand}>{children}</BrandContext.Provider>
}
// Brand hook — context-backed so consumers re-render when the runtime catalog
// loads. Same return shape as before (a BrandConfig).
export function useBrand(): BrandConfig {
return useContext(BrandContext)
}
+132 -243
View File
@@ -1,5 +1,21 @@
// White-label branding configuration
// Configure via environment variables for different deployments
// White-label branding — runtime-resolved, brand-neutral source.
//
// This module knows ONE brand: bootnode's own neutral defaults (the built-in
// fallback). Every real brand (lux / zoo / pars / hanzo / …) is supplied at
// RUNTIME from an external catalog injected per-deploy — bootnode the app ships
// no brand names, domains, or links in source.
//
// Mechanism (mirrors the @hanzo/id SPA's runtime config loader):
// 1. The deploy serves `/config.json` and/or injects a `window.__BRAND_CATALOG__`
// global (a host->BrandConfig map, or a string of one) into the page.
// 2. `applyBrandCatalog()` ingests it; `getBrandKey()` resolves the active
// brand by `window.location.hostname` against the catalog.
// 3. `getBrand()` returns the resolved BrandConfig, falling back to the
// built-in neutral `bootnode` defaults when no catalog entry matches.
//
// The full multi-brand catalog lives in the DEPLOY config (universe / CF-Pages),
// NOT in this repo. See `public/config.json` for the contract + the README note
// in the PR for where the per-host catalog is provisioned.
export type BrandConfig = {
name: string
@@ -45,265 +61,138 @@ export type BrandConfig = {
}[]
}
// Brand presets
const brands: Record<string, BrandConfig> = {
bootnode: {
name: "Bootnode",
tagline: "Blockchain Infrastructure for Developers",
description: "The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
logo: "/logo/bootnode-logo.svg",
logoIcon: "/logo/bootnode-icon.svg",
logoWhite: "/logo/bootnode-logo-white.svg",
favicon: "/logo/bootnode-icon.svg",
domain: "bootno.de",
apiUrl: "https://api.bootno.de",
wsUrl: "wss://ws.bootno.de",
statusUrl: "https://status.bootno.de",
defaultTheme: "light",
colors: {
primary: "#000000",
primaryForeground: "#ffffff",
accent: "#3b82f6", // blue-500
accentEnd: "#06b6d4", // cyan-500
},
social: {
twitter: "https://twitter.com/bootnode",
github: "https://github.com/bootnode",
},
iam: {
url: "https://hanzo.id",
clientId: "bootnode-platform",
domain: "hanzo.id",
},
footerLinks: [
{ name: "GitHub", href: "https://github.com/bootnode", external: true },
{ name: "Twitter", href: "https://twitter.com/bootnode", external: true },
],
ecosystemApps: [],
},
hanzo: {
name: "Hanzo Web3",
tagline: "Enterprise Blockchain Infrastructure",
description: "Enterprise blockchain infrastructure powered by Hanzo AI. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, and more.",
logo: "/logo/hanzo-logo.svg",
logoIcon: "/logo/hanzo-icon.svg",
logoWhite: "/logo/hanzo-logo-white.svg",
favicon: "/logo/hanzo-icon.svg",
domain: "web3.hanzo.ai",
apiUrl: "https://api.cloud.hanzo.ai",
wsUrl: "wss://ws.cloud.hanzo.ai",
statusUrl: "https://status.hanzo.ai",
defaultTheme: "dark",
colors: {
primary: "#fd4444",
primaryForeground: "#ffffff",
accent: "#fd4444", // hanzo red
accentEnd: "#ff6b6b", // lighter red
},
social: {
twitter: "https://twitter.com/hanaboratory",
github: "https://github.com/hanzoai",
discord: "https://discord.gg/hanzo",
},
iam: {
url: "https://hanzo.id",
clientId: "hanzo-web3",
domain: "hanzo.id",
},
footerLinks: [
{ name: "Hanzo AI", href: "https://hanzo.ai", external: true },
{ name: "Hanzo Cloud", href: "https://cloud.hanzo.ai", external: true },
{ name: "Hanzo Chat", href: "https://chat.hanzo.ai", external: true },
{ name: "Hanzo Network", href: "https://hanzo.network", external: true },
{ name: "GitHub", href: "https://github.com/hanzoai", external: true },
],
ecosystemApps: [
{ name: "Hanzo Chat", href: "https://chat.hanzo.ai", description: "AI chat with 100+ models", icon: "MessageSquare", category: "tools" },
{ name: "Hanzo Console", href: "https://console.hanzo.ai", description: "LLM observability & evaluation", icon: "BarChart3", category: "tools" },
],
},
lux: {
name: "Lux Cloud",
tagline: "Next-Gen Blockchain Infrastructure",
description: "High-performance blockchain infrastructure for the Lux Network ecosystem.",
logo: "/logo/lux-wordmark-dark.svg",
logoIcon: "/logo/lux-icon.svg",
logoWhite: "/logo/lux-wordmark-white.svg",
favicon: "/logo/lux-icon.svg",
domain: "cloud.lux.network",
apiUrl: "https://api.cloud.lux.network",
wsUrl: "wss://ws.cloud.lux.network",
statusUrl: "https://status.lux.network",
defaultTheme: "dark",
colors: {
primary: "#ffffff",
primaryForeground: "#000000",
accent: "#a8a8a8", // monochrome silver
accentEnd: "#ffffff", // white
},
social: {
twitter: "https://twitter.com/luxdefi",
github: "https://github.com/luxfi",
},
iam: {
url: "https://lux.id",
clientId: "lux-cloud",
domain: "lux.id",
},
footerLinks: [
{ name: "Lux Network", href: "https://lux.network", external: true },
{ name: "Lux Explorer", href: "https://explore.lux.network", external: true },
{ name: "Lux Bridge", href: "https://bridge.lux.network", external: true },
{ name: "Lux Wallet", href: "https://wallet.lux.network", external: true },
{ name: "Lux Exchange", href: "https://exchange.lux.network", external: true },
{ name: "GitHub", href: "https://github.com/luxfi", external: true },
],
ecosystemApps: [
{ name: "Explorer", href: "https://explore.lux.network", description: "Block explorer for all Lux chains", icon: "Search", category: "explore" },
{ name: "Bridge", href: "https://bridge.lux.network", description: "Cross-chain asset bridge", icon: "ArrowLeftRight", category: "defi" },
{ name: "Exchange", href: "https://exchange.lux.network", description: "Decentralized exchange (DEX)", icon: "ArrowUpDown", category: "defi" },
{ name: "Markets", href: "https://markets.lux.network", description: "NFT marketplace", icon: "ShoppingBag", category: "defi" },
{ name: "Wallet", href: "https://wallet.lux.network", description: "Multi-chain wallet", icon: "Wallet", category: "tools" },
{ name: "Safe", href: "https://safe.lux.network", description: "Multisig wallet (FROST)", icon: "Shield", category: "tools" },
{ name: "Faucet", href: "https://faucet.lux.network", description: "Testnet token faucet", icon: "Droplets", category: "tools" },
{ name: "Staking", href: "https://stake.lux.network", description: "Validator staking", icon: "Coins", category: "defi" },
{ name: "Governance", href: "https://vote.lux.network", description: "On-chain governance", icon: "Vote", category: "governance" },
{ name: "Status", href: "https://status.lux.network", description: "Network status & uptime", icon: "Activity", category: "explore" },
{ name: "Lux Build", href: "https://lux.build", description: "Developer portal & docs", icon: "Code", category: "tools" },
],
},
pars: {
name: "Pars Cloud",
tagline: "Sovereign L1 Infrastructure",
description: "Sovereign L1 blockchain infrastructure for the Pars Network. Post-quantum secure, SessionVM messaging, native DEX, and more.",
logo: "/logo/pars-logo.svg",
logoIcon: "/logo/pars-icon.svg",
logoWhite: "/logo/pars-logo-white.svg",
favicon: "/logo/pars-icon.svg",
domain: "cloud.pars.network",
apiUrl: "https://api.cloud.pars.network",
wsUrl: "wss://ws.cloud.pars.network",
statusUrl: "https://status.pars.network",
defaultTheme: "dark",
colors: {
primary: "#00abff",
primaryForeground: "#ffffff",
accent: "#00abff", // persian blue
accentEnd: "#F59E0B", // gold accent
},
social: {
twitter: "https://twitter.com/parsnetwork",
github: "https://github.com/pars-network",
},
iam: {
url: "https://pars.id",
clientId: "pars-app-client-id",
domain: "pars.id",
},
footerLinks: [
{ name: "Pars Network", href: "https://pars.network", external: true },
{ name: "Pars Explorer", href: "https://explore.pars.network", external: true },
{ name: "Pars Wallet", href: "https://wallet.pars.network", external: true },
{ name: "SessionVM", href: "https://session.pars.network", external: true },
{ name: "GitHub", href: "https://github.com/pars-network", external: true },
],
ecosystemApps: [
{ name: "Explorer", href: "https://explore.pars.network", description: "Pars block explorer", icon: "Search", category: "explore" },
{ name: "Wallet", href: "https://wallet.pars.network", description: "Pars wallet", icon: "Wallet", category: "tools" },
{ name: "SessionVM", href: "https://session.pars.network", description: "Private messaging VM", icon: "MessageCircle", category: "tools" },
{ name: "Faucet", href: "https://faucet.pars.network", description: "Testnet tokens", icon: "Droplets", category: "tools" },
],
},
zoo: {
name: "Zoo Labs",
tagline: "Decentralized AI Infrastructure",
description: "Blockchain infrastructure for decentralized AI and science research.",
logo: "/logo/zoo-logo.svg",
logoIcon: "/logo/zoo-icon.svg",
logoWhite: "/logo/zoo-logo-white.svg",
favicon: "/logo/zoo-icon.svg",
domain: "web3.zoo.ngo",
apiUrl: "https://api.web3.zoo.ngo",
wsUrl: "wss://ws.web3.zoo.ngo",
statusUrl: "https://status.zoo.ngo",
defaultTheme: "dark",
colors: {
primary: "#00cc66",
primaryForeground: "#ffffff",
accent: "#00cc66", // emerald green
accentEnd: "#10b981", // lighter green
},
social: {
twitter: "https://twitter.com/zoolabs",
github: "https://github.com/zoolabs",
},
iam: {
url: "https://zoolabs.id",
clientId: "zoo-app-client-id",
domain: "zoolabs.id",
},
footerLinks: [
{ name: "Zoo Labs", href: "https://zoo.ngo", external: true },
{ name: "Zoo AI Chat", href: "https://ai.zoo.ngo", external: true },
{ name: "Zoo Network", href: "https://zoo.network", external: true },
{ name: "Zen LM", href: "https://zenlm.ai", external: true },
{ name: "GitHub", href: "https://github.com/zooai", external: true },
],
ecosystemApps: [
{ name: "Zoo AI Chat", href: "https://ai.zoo.ngo", description: "Decentralized AI chat", icon: "Bot", category: "tools" },
{ name: "Zen LM", href: "https://zenlm.ai", description: "Open frontier models", icon: "Brain", category: "tools" },
{ name: "Explorer", href: "https://explore.zoo.ngo", description: "Zoo chain explorer", icon: "Search", category: "explore" },
{ name: "Faucet", href: "https://faucet.zoo.ngo", description: "Testnet tokens", icon: "Droplets", category: "tools" },
],
},
// Runtime catalog: host substring -> brand key, plus key -> BrandConfig.
// Both are empty in source. Populated at runtime by `applyBrandCatalog()` from
// the deploy-injected config (see public/config.json for the shape).
export type BrandCatalog = {
// Ordered host matchers. First whose `match` is a substring of the hostname wins.
hosts?: { match: string; key: string }[]
// Brand definitions, keyed by brand key (e.g. "lux", "zoo").
brands?: Record<string, BrandConfig>
// Default brand key for unknown hosts (defaults to "bootnode").
defaultKey?: string
}
// Get brand from domain auto-detection or environment fallback
// The ONE built-in brand: bootnode's neutral defaults. Used for dev, SSG, and
// any host with no catalog entry. Intentionally generic — no third-party brand.
const BOOTNODE_DEFAULT: BrandConfig = {
name: "Bootnode",
tagline: "Blockchain Infrastructure for Developers",
description:
"The complete blockchain development platform. Multi-chain RPC, Token APIs, NFT APIs, Smart Wallets, Webhooks, and more.",
logo: "/logo.svg",
logoIcon: "/logo.svg",
logoWhite: "/logo.svg",
favicon: "/logo.svg",
domain: "bootno.de",
apiUrl: "https://api.bootno.de",
wsUrl: "wss://ws.bootno.de",
statusUrl: "https://status.bootno.de",
defaultTheme: "light",
colors: {
primary: "#000000",
primaryForeground: "#ffffff",
accent: "#3b82f6",
accentEnd: "#06b6d4",
},
social: {},
iam: {
url: "https://hanzo.id",
clientId: "bootnode-web",
domain: "hanzo.id",
},
footerLinks: [],
ecosystemApps: [],
}
const DEFAULT_KEY = "bootnode"
// Active runtime catalog. Starts empty (source ships no brands). The deploy
// fills it via `applyBrandCatalog()` before/at hydration.
let catalog: BrandCatalog = {}
/**
* Parse a raw catalog value (object or JSON string) into a BrandCatalog.
* Returns {} on any error — never throws (mirrors id SPA's `parseCatalog`).
*/
export function parseBrandCatalog(raw: unknown): BrandCatalog {
if (!raw) return {}
try {
const obj = typeof raw === "string" ? JSON.parse(raw) : raw
if (!obj || typeof obj !== "object") return {}
return obj as BrandCatalog
} catch {
return {}
}
}
/**
* Install the runtime brand catalog. Idempotent — safe to call repeatedly
* (e.g. once from the inline boot script, once after the /config.json fetch).
* Accepts either a parsed object or a raw JSON string.
*/
export function applyBrandCatalog(raw: unknown): void {
const next = parseBrandCatalog(raw)
if (Object.keys(next).length > 0) catalog = next
}
/**
* Read the catalog injected as a global by the deploy
* (`window.__BRAND_CATALOG__`). Synchronous — available before the first
* render when the deploy inlines it in the document head.
*/
function readInjectedCatalog(): BrandCatalog {
if (typeof window === "undefined") return {}
const w = window as unknown as { __BRAND_CATALOG__?: unknown }
return parseBrandCatalog(w.__BRAND_CATALOG__)
}
function activeCatalog(): BrandCatalog {
if (catalog.brands || catalog.hosts) return catalog
return readInjectedCatalog()
}
/**
* Resolve the active brand key from the hostname against the runtime catalog.
* No hardcoded host switches — the host->key table is data, supplied at deploy.
*/
function getBrandKey(): string {
// Client-side: detect from domain (highest priority)
if (typeof window !== "undefined") {
const hostname = window.location.hostname
if (hostname.includes("lux.cloud") || hostname.includes("lux.network")) return "lux"
if (hostname.includes("pars.network") || hostname.includes("pars.id")) return "pars"
if (hostname.includes("zoo.ngo") || hostname.includes("zoo.network") || hostname.includes("zoo.cloud")) return "zoo"
if (hostname.includes("hanzo.ai")) return "hanzo"
if (hostname.includes("bootno.de") || hostname.includes("bootnode.io")) return "bootnode"
const cat = activeCatalog()
const fallback = cat.defaultKey || DEFAULT_KEY
if (typeof window === "undefined") return fallback
const hostname = window.location.hostname.toLowerCase()
for (const rule of cat.hosts ?? []) {
if (rule.match && hostname.includes(rule.match.toLowerCase())) return rule.key
}
// Server-side: BRAND env var (runtime, not baked by Next.js unlike NEXT_PUBLIC_*)
const runtimeBrand = process.env.BRAND?.toLowerCase()
if (runtimeBrand && brands[runtimeBrand]) {
return runtimeBrand
}
// Fall back to build-time NEXT_PUBLIC_BRAND
const envBrand = process.env.NEXT_PUBLIC_BRAND?.toLowerCase()
if (envBrand && brands[envBrand]) {
return envBrand
}
return "bootnode"
return fallback
}
// Export the active brand configuration
// Export the active brand configuration. Synchronous so module-level callers
// (`const brand = getBrand()`) keep working; resolves against the injected
// catalog when present, else the built-in neutral default.
export function getBrand(): BrandConfig {
return brands[getBrandKey()]
const cat = activeCatalog()
const key = getBrandKey()
return cat.brands?.[key] ?? cat.brands?.[DEFAULT_KEY] ?? BOOTNODE_DEFAULT
}
// Export brand key for conditional rendering
// Export brand key for conditional rendering / per-brand local maps
// (SDK package names, region data, localStorage namespacing).
export function getBrandKey_(): string {
return getBrandKey()
}
// Check if using Hanzo branding
// Check if the active brand is the Hanzo brand.
export function isHanzoBrand(): boolean {
return getBrandKey() === "hanzo"
}
// Get all available brands (for admin/testing)
// Get all brands currently known at runtime (for admin/testing). Empty until a
// catalog is injected — source ships no brands.
export function getAllBrands(): Record<string, BrandConfig> {
return brands
return activeCatalog().brands ?? { [DEFAULT_KEY]: BOOTNODE_DEFAULT }
}
// Default export for convenience
// Default export for convenience (neutral default at module-eval / SSG time;
// the BrandProvider re-resolves client-side once the runtime catalog loads).
export const brand = getBrand()
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
sharp: true
+3
View File
@@ -6,3 +6,6 @@
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
/config.json
Cache-Control: no-store, must-revalidate
+6
View File
@@ -0,0 +1,6 @@
{
"_comment": "Runtime brand catalog — NEUTRAL PLACEHOLDER. The deploy (universe / CF-Pages) OVERWRITES this file (and/or injects the same JSON as window.__BRAND_CATALOG__) with the full multi-brand catalog. Shipping empty `brands` makes the app fall back to bootnode's built-in neutral default. Shape mirrors the @hanzo/id SPA's /config.json contract. See lib/brand.ts (BrandCatalog / BrandConfig).",
"defaultKey": "bootnode",
"hosts": [],
"brands": {}
}