rewrite(bridge): Next.js → Vite 8 SPA + hanzoai/spa

- Convert app/bridge/ from Next.js 14 app-router to Vite 8 SPA.
- New entry: index.html → src/main.tsx → src/App.tsx (wouter router).
- src/pages/{HomePage,AuthPage,CampaignsPage,CampaignDetailPage,SalonPage,
  SwapPage,TeleporterSwapPage,UtilaSwapPage,TransactionsPage,NotFoundPage}.tsx.
- src/shims/next-{link,image,navigation,dynamic,headers,server,font/google,mdx}
  + sentry-nextjs map Next.js APIs onto wouter + plain DOM so existing
  components compile unchanged. Aliased in vite.config.ts.
- Dockerfile (root + app/bridge): FROM node:22-alpine AS build → vite build →
  FROM ghcr.io/hanzoai/spa COPY dist /public.
- Delete app-router dir, next.config.js, next-env.d.ts, middleware.ts,
  sentry.*.ts. Keep app/explorer/ and docs/ untouched (still Next.js).

Tenant route resolution:
  src/app/api/tenant/route.ts was a 17-line edge handler that called
  lib/tenant.fetchTenant(). fetchTenant() only reads env vars and fetches a
  public IAM /api/get-organization endpoint. No secrets. Moved to client:
  lib/tenant.ts now reads import.meta.env.VITE_* and is called directly from
  main.tsx + TenantProvider. No sidecar needed.

TODO:
  - `pnpm install` in CI to pick up wouter, vite, @vitejs/plugin-react,
    @sentry/browser (added to package.json).
  - Pre-existing viem/teleport tsc errors remain (ignored as before via esbuild).
  - Storybook config still references @storybook/nextjs — rewire to
    @storybook/react-vite separately.
This commit is contained in:
2026-04-19 00:05:10 -07:00
parent cc67d9eb05
commit fbd08c4e1f
51 changed files with 805 additions and 830 deletions
+11 -68
View File
@@ -1,72 +1,15 @@
# syntax=docker/dockerfile:1
# Bridge - Next.js standalone build
FROM node:18-alpine AS deps
# syntax=docker/dockerfile:1.7
#
# Bridge — Vite 8 SPA served by ghcr.io/hanzoai/spa.
# Build context: repo root (pnpm workspace).
FROM node:22-alpine AS build
RUN apk add --no-cache libc6-compat python3 make g++ git
RUN corepack enable pnpm
RUN corepack enable
WORKDIR /app
# Copy workspace config
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY app/bridge/package.json ./app/bridge/
COPY pkg/core/package.json ./pkg/core/
COPY pkg/settings/package.json ./pkg/settings/
# Install dependencies (cache pnpm store across builds)
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --no-frozen-lockfile
# Builder stage
FROM node:18-alpine AS builder
RUN apk add --no-cache libc6-compat
RUN corepack enable pnpm
WORKDIR /app
# Copy full workspace from deps (includes all node_modules with pnpm links)
COPY --from=deps /app ./
# Copy source files on top (.dockerignore excludes node_modules)
COPY . .
RUN pnpm install --frozen-lockfile || pnpm install
RUN pnpm -C app/bridge build
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_OPTIONS=--max_old_space_size=4096
# Build-time env vars for Next.js SSR (overridable via --build-arg)
ARG NEXT_PUBLIC_BRIDGE_API=https://bridge-api.lux.network
ARG NEXT_PUBLIC_BACKEND_API=https://bridge-api.lux.network
ARG NEXT_PUBLIC_IDENTITY_API=https://bridge-api.lux.network
ARG NEXT_PUBLIC_EXPLORER_API=https://bridge-api.lux.network
ARG NEXT_PUBLIC_NETWORK_NAME=mainnet
ENV NEXT_PUBLIC_BRIDGE_API=$NEXT_PUBLIC_BRIDGE_API
ENV NEXT_PUBLIC_BACKEND_API=$NEXT_PUBLIC_BACKEND_API
ENV NEXT_PUBLIC_IDENTITY_API=$NEXT_PUBLIC_IDENTITY_API
ENV NEXT_PUBLIC_EXPLORER_API=$NEXT_PUBLIC_EXPLORER_API
ENV NEXT_PUBLIC_NETWORK_NAME=$NEXT_PUBLIC_NETWORK_NAME
WORKDIR /app/app/bridge
RUN pnpm build
# Runner stage
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 bridge
COPY --from=builder /app/app/bridge/public ./public
COPY --from=builder --chown=bridge:nodejs /app/app/bridge/.next/standalone ./
COPY --from=builder --chown=bridge:nodejs /app/app/bridge/.next/static ./app/bridge/.next/static
COPY docker/entrypoint.sh /app/entrypoint.sh
USER bridge
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["node", "app/bridge/server.js"]
FROM ghcr.io/hanzoai/spa
COPY --from=build /app/app/bridge/dist /public
+10 -61
View File
@@ -1,66 +1,15 @@
# Build stage
FROM node:22-alpine AS builder
# syntax=docker/dockerfile:1.7
#
# Bridge — Vite SPA served by ghcr.io/hanzoai/spa
# Builds inside the monorepo (context = repo root) so workspace deps resolve.
FROM node:22-alpine AS build
RUN apk add --no-cache libc6-compat python3 make g++ git
RUN corepack enable
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare [email protected] --activate
# Copy root workspace files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
# Copy ALL workspace package.json files for proper resolution
COPY app/bridge/package.json ./app/bridge/
COPY app/bridge3/package.json ./app/bridge3/
COPY app/explorer/package.json ./app/explorer/
COPY app/server/package.json ./app/server/
COPY pkg/core/package.json ./pkg/core/
COPY pkg/settings/package.json ./pkg/settings/
COPY pkg/threshold/package.json ./pkg/threshold/
COPY pkg/ui/package.json ./pkg/ui/
COPY pkg/utila/package.json ./pkg/utila/
# Install dependencies
RUN pnpm install --no-frozen-lockfile
# Copy all source code (needed for workspace deps at build time)
COPY . .
RUN pnpm install --frozen-lockfile || pnpm install
RUN pnpm -C app/bridge build
# Set Next.js to standalone mode
ENV NEXT_TELEMETRY_DISABLED=1
# Build the bridge app
RUN cd app/bridge && NODE_OPTIONS=--max_old_space_size=2048 pnpm run build
# Runtime stage
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Add non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/app/bridge/public ./public
COPY --from=builder /app/app/bridge/package.json ./package.json
# Copy standalone build
COPY --from=builder --chown=nextjs:nodejs /app/app/bridge/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/app/bridge/.next/static ./app/bridge/.next/static
# Switch to non-root user
USER nextjs
# Expose port
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start the application
CMD ["node", "app/bridge/server.js"]
FROM ghcr.io/hanzoai/spa
COPY --from=build /app/app/bridge/dist /public
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en" class="dark" style="background-color: #000">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>Bridge</title>
<meta name="description" content="Cross-chain bridge" />
<!-- Basel Grotesk — loaded via @font-face in styles.css -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-29
View File
@@ -1,29 +0,0 @@
// https://react-svgr.com/docs/next
module.exports = (config) => {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
)
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: fileLoaderRule.issuer,
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
use: ['@svgr/webpack'],
},
)
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i
return config
}
-36
View File
@@ -1,36 +0,0 @@
const watchExternalFilesPlugin = require('webpack-watch-files-plugin').default
const disable = false
const localModules = [
//'../../../../hanzoai/react-sdk/packages/ui',
//'../../../../hanzoai/react-sdk/packages/commerce'
]
const suffixes = [
'/**/*.ts',
'/**/*.tsx',
]
const flatArray = localModules.map((p) => {
return suffixes.map((s) => (p + s))
}).flat()
module.exports = ( config ) => {
if (disable) {
return config
}
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300,
}
config.plugins.push(
new watchExternalFilesPlugin({
files: flatArray,
})
)
return config
}
-5
View File
@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
-126
View File
@@ -1,126 +0,0 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const withMDX = require("@next/mdx")();
const { PHASE_PRODUCTION_SERVER } = require("next/constants");
const path = require("path")
const svgrPluginConfig = require('./next-conf/svgr.next.config')
const securityHeaders = [
// { key: "Access-Control-Allow-Origin", value: "*" },
{
key: "X-Frame-Options",
value: "SAMEORIGIN",
},
{
key: "Content-Security-Policy",
value: "frame-ancestors *.immutable.com",
},
];
module.exports = (phase, { defaultConfig }) => {
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
output: 'standalone',
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
},
basePath: process.env.APP_BASE_PATH || '',
pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'],
reactStrictMode: false,
images: {
remotePatterns: [
{
protocol: "https",
hostname: "stagelslayerswapbridgesa.blob.core.windows.net",
},
{
protocol: "https",
hostname: "devlslayerswapbridgesa.blob.core.windows.net",
},
{
protocol: "https",
hostname: "prodlslayerswapbridgesa.blob.core.windows.net",
},
{
protocol: "https",
hostname: "cdn.lux.network",
},
{
protocol: 'https',
hostname: 'img.youtube.com',
pathname: '**',
},
{
protocol: "https",
hostname: "arweave.net",
},
{
protocol: "http",
hostname: "localhost",
},
// White-label tenant logo domains
{ protocol: "https", hostname: "lux.network" },
{ protocol: "https", hostname: "pars.network" },
{ protocol: "https", hostname: "hanzo.ai" },
{ protocol: "https", hostname: "zoo.ngo" },
{ protocol: "https", hostname: "*.lux.network" },
{ protocol: "https", hostname: "*.hanzo.ai" },
{ protocol: "https", hostname: "*.pars.network" },
{ protocol: "https", hostname: "*.zoo.ngo" },
],
},
compiler: {
removeConsole: false,
},
webpack: (config, { isServer, dev }) => {
config.cache = false; // Disable Webpack's persistent caching
config.externals.push("pino-pretty", "lokijs", "encoding");
config.resolve.fallback = { fs: false, net: false, tls: false };
// Firebase + @hanzo/auth removed — bridge uses JWT auth, not Firebase.
// Prevent @luxfi/ui transitive deps from being bundled.
config.resolve.alias['firebase/app'] = false;
config.resolve.alias['firebase/auth'] = false;
config.resolve.alias['firebase/firestore'] = false;
config.resolve.alias['firebase/analytics'] = false;
config.resolve.alias['firebase/storage'] = false;
config.resolve.alias['firebase-admin'] = false;
config.resolve.alias['firebase-admin/app'] = false;
config.resolve.alias['@hanzo/auth'] = false;
config.resolve.alias['@hanzo/commerce'] = false;
config.resolve.alias['@'] = path.resolve(__dirname, 'src');
let conf = svgrPluginConfig(config)
// if (dev) {
// conf = watchPluginConfig(conf)
// }
return conf
},
productionBrowserSourceMaps: true,
// https://stackoverflow.com/questions/72621835/how-to-fix-you-may-need-an-appropriate-loader-to-handle-this-file-type-current
transpilePackages: [
'@hanzo/ui',
'@luxfi/ui',
'@luxfi/data',
'@luxfi/menu-icons'
],
};
if (phase === PHASE_PRODUCTION_SERVER) {
nextConfig.headers = async () => {
return [
{
// Apply these headers to all routes in your application.
source: "/:path*",
headers: securityHeaders,
},
];
};
}
return withMDX(nextConfig);
};
+13 -15
View File
@@ -2,18 +2,17 @@
"name": "@luxbridge/app",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"prebuild:linux": "export NODE_OPTIONS=--max_old_space_size=4096",
"build": "next build",
"start": "next start",
"dev": "vite",
"build": "vite build",
"start": "vite preview --port 3000 --host 0.0.0.0",
"preview": "vite preview --port 3000 --host 0.0.0.0",
"tc": "tsc --noEmit",
"clean": "rm -rf ./.next && rm -rf ./node_modules && rm -rf ../../node_modules",
"cn": "rm -rf ./.next",
"typecheck": "tsc --noEmit",
"clean": "rm -rf ./dist ./node_modules ./.next",
"storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"analyze": "cross-env ANALYZE=true next build",
"lint": "next lint"
"build:storybook": "storybook build"
},
"dependencies": {
"@eth-optimism/contracts-ts": "^0.15.0",
@@ -24,10 +23,9 @@
"@loopring-web/web3-provider": "1.4.13",
"@luxfi/ui": "catalog:",
"@metamask/jazzicon": "^2.0.0",
"@next/mdx": "14.1.0",
"@prisma/client": "^5.21.1",
"@rainbow-me/rainbowkit": "^2.0.5",
"@sentry/nextjs": "^7.80.1",
"@sentry/browser": "^7.80.1",
"@solana/spl-token": "^0.4.9",
"@solana/wallet-adapter-base": "^0.9.23",
"@solana/wallet-adapter-coinbase": "^0.1.19",
@@ -62,7 +60,6 @@
"lucide-react": "catalog:",
"markdown-to-jsx": "^7.4.0",
"multicoin-address-validator": "^0.5.21",
"next": "catalog:",
"next-themes": "^0.2.1",
"qrcode.react": "^3.1.0",
"react": "catalog:",
@@ -80,22 +77,23 @@
"viem": "^2.9.9",
"wagmi": "^2.5.19",
"web3": "^4.11.1",
"wouter": "^3.0.0",
"zksync": "^0.13.1",
"zustand": "^4.4.1"
},
"devDependencies": {
"@mdx-js/loader": "^3.0.0",
"@mdx-js/react": "^3.0.0",
"@next/bundle-analyzer": "^13.5.4",
"@storybook/addon-essentials": "^7.4.5",
"@storybook/addon-interactions": "^7.4.5",
"@storybook/addon-links": "^7.4.5",
"@storybook/addon-mdx-gfm": "^7.4.5",
"@storybook/addon-onboarding": "^1.0.8",
"@storybook/blocks": "^7.4.5",
"@storybook/nextjs": "^7.4.5",
"@storybook/react": "^7.4.5",
"@storybook/react-vite": "^7.4.5",
"@storybook/testing-library": "^0.2.1",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^8.0.0",
"@types/bn.js": "^5.1.0",
"@types/crypto-js": "^4.1.1",
"@types/lodash.merge": "^4.6.9",
+2 -3
View File
@@ -1,8 +1,7 @@
module.exports = {
export default {
plugins: {
'postcss-import': {},
'tailwindcss/nesting': "postcss-nesting",
'tailwindcss/nesting': 'postcss-nesting',
tailwindcss: {},
autoprefixer: {},
},
-34
View File
@@ -1,34 +0,0 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://ab04115a318589cc7c77fd3d48ce9bbb@o4506258334875648.ingest.sentry.io/4506258338217984",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
beforeSend(event, hint) {
const extension_error = event.exception?.values?.some(e => e.stacktrace?.frames?.some(f => f.filename?.includes("app://")))
const isKnownError = event.exception?.values?.some(e => known_errors.includes(e.value || ""))
if (isKnownError || (event.transaction !== "error_boundary_handler" && extension_error))
return null;
return event;
},
});
const known_errors = [
"Cannot write private member to an object whose class did not declare it",
"Socket stalled when trying to connect to wss://relay.walletconnect.org"
]
-16
View File
@@ -1,16 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://ab04115a318589cc7c77fd3d48ce9bbb@o4506258334875648.ingest.sentry.io/4506258338217984",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
-15
View File
@@ -1,15 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: "https://ab04115a318589cc7c77fd3d48ce9bbb@o4506258334875648.ingest.sentry.io/4506258338217984",
// Adjust this value in production, or use tracesSampler for greater control
tracesSampleRate: 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});
+119
View File
@@ -0,0 +1,119 @@
import { ChevronDown } from 'lucide-react'
import { lazy, Suspense, useEffect, useState, type FC } from 'react'
import { Route, Switch, useRoute } from 'wouter'
import Contexts from '@/components/Contexts'
import Header from '@/components/header'
import Main from '@/components/main'
import { BridgeAppSettings } from '@/Models/BridgeAppSettings'
import { type BridgeSettings } from '@/Models/BridgeSettings'
import getBridgeSettings from '@/util/getBridgeSettings'
import siteDef from '@/site-def'
import { fetchTenant, getTenant, type TenantConfig } from '@/lib/tenant'
import HomePage from '@/pages/HomePage'
import NotFoundPage from '@/pages/NotFoundPage'
const AuthPage = lazy(() => import('@/pages/AuthPage'))
const CampaignsPage = lazy(() => import('@/pages/CampaignsPage'))
const CampaignDetailPage = lazy(() => import('@/pages/CampaignDetailPage'))
const SalonPage = lazy(() => import('@/pages/SalonPage'))
const SwapPage = lazy(() => import('@/pages/SwapPage'))
const TeleporterSwapPage = lazy(() => import('@/pages/TeleporterSwapPage'))
const UtilaSwapPage = lazy(() => import('@/pages/UtilaSwapPage'))
const TransactionsPage = lazy(() => import('@/pages/TransactionsPage'))
const ConnectedWallets = lazy(() =>
import('@/components/ConnectedWallets').then((m) => ({ default: m.ConnectedWallets })),
)
// wouter route components take no params; pages read via useRoute + window.__NEXT_PARAMS__
function withParam<P extends string>(
Component: FC<{ params: Record<P, string> }>,
paramName: P,
): FC {
return function Wrapped() {
const [, params] = useRoute<Record<P, string>>(`/*`)
const value = (params as Record<P, string> | null)?.[paramName] ?? ''
return <Component params={{ [paramName]: value } as Record<P, string>} />
}
}
function RouteWithParams<P extends string>({ path, component: C, paramName }: {
path: string
component: FC<{ params: Record<P, string> }>
paramName: P
}) {
return (
<Route path={path}>
{(params: Record<string, string>) => (
<C params={{ [paramName]: params[paramName] ?? '' } as Record<P, string>} />
)}
</Route>
)
}
export function App() {
const [settings, setSettings] = useState<BridgeSettings | undefined>(undefined)
const [tenant, setTenant] = useState<TenantConfig>(() => getTenant())
useEffect(() => {
void getBridgeSettings().then(setSettings).catch(() => setSettings(undefined))
void fetchTenant().then(setTenant).catch(() => setTenant(getTenant()))
}, [])
const content = (
<Suspense fallback={null}>
<Switch>
<Route path="/" component={HomePage} />
<Route path="/auth" component={AuthPage} />
<Route path="/campaigns" component={CampaignsPage} />
<RouteWithParams path="/campaigns/:campaign" component={CampaignDetailPage} paramName="campaign" />
<Route path="/salon" component={SalonPage} />
<RouteWithParams path="/swap/:swapId" component={SwapPage} paramName="swapId" />
<RouteWithParams path="/swap/teleporter/:swapId" component={TeleporterSwapPage} paramName="swapId" />
<RouteWithParams path="/swap/v2/:swapId" component={UtilaSwapPage} paramName="swapId" />
<Route path="/transactions" component={TransactionsPage} />
<Route component={NotFoundPage} />
</Switch>
</Suspense>
)
if (!settings) {
// Settings not loaded yet — render minimal shell
return (
<Main>
<div className="flex items-center justify-center min-h-screen">
<p className="text-muted-foreground">Loading...</p>
</div>
</Main>
)
}
return (
<Contexts settings={settings}>
<Header siteDef={siteDef} logoVariant="logo-only" logoSrc={tenant.logoUrl}>
<Suspense fallback={null}>
<ConnectedWallets
connectButtonVariant="outline"
showWalletIcon={false}
connectButtonClx="pl-3 pr-2 flex rounded-lg relative"
>
<span className="pr-1.5">Connect</span>
<span className="pr-1">|</span>
<span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</span>
</ConnectedWallets>
</Suspense>
</Header>
<Main>{content}</Main>
<footer className="py-4 text-center text-xs text-muted-foreground">{tenant.name}</footer>
</Contexts>
)
}
// Suppress the unused warning for BridgeAppSettings import (used via SettingsProvider in Contexts)
void BridgeAppSettings
// Suppress unused — left here in case future routes need direct RouteWithParams wrapping via withParam helper
void withParam
-17
View File
@@ -1,17 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { fetchTenant } from '@/lib/tenant'
export const runtime = 'edge'
/**
* GET /api/tenant — returns tenant config derived from IAM org.
* Client-side components call this to get branding (name, logo, colors).
* Cached for 60s at edge, revalidates via IAM every 5 min server-side.
*/
export async function GET(_req: NextRequest) {
const tenant = await fetchTenant()
return NextResponse.json(tenant, {
headers: { 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300' },
})
}
-10
View File
@@ -1,10 +0,0 @@
import nextDynamic from 'next/dynamic'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const AuthContent = nextDynamic(() => import('@/components/AuthContent'), { ssr: false })
export default function AuthPage() {
return <AuthContent />
}
@@ -1,15 +0,0 @@
import React from 'react'
import CampaignDetails from '@/components/Campaigns/Details'
export const dynamic = 'force-dynamic'
const CampaignPage: React.FC<{
params: { campaign: string }
}> = ({
params: { campaign }
}) => (
<CampaignDetails campaign={campaign}/>
)
export default CampaignPage
-9
View File
@@ -1,9 +0,0 @@
import Campaigns from '@/components/Campaigns'
export const dynamic = 'force-dynamic'
const CampaignsPage: React.FC = () => (
<Campaigns />
)
export default CampaignsPage
-88
View File
@@ -1,88 +0,0 @@
import React, { type PropsWithChildren } from 'react'
import dynamic from "next/dynamic"
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
import '../styles/globals.css'
import '../styles/dialog-transition.css'
import '@rainbow-me/rainbowkit/styles.css'
import siteDef from '@/site-def'
import Contexts from '@/components/Contexts'
import Main from '@/components/main'
import Header from '@/components/header'
import { ChevronDown } from 'lucide-react'
import getBridgeSettings from '@/util/getBridgeSettings'
import { fetchTenant, type TenantConfig } from '@/lib/tenant'
const ConnectedWallets = dynamic(
() => import("../components/ConnectedWallets").then((comp) => (comp.ConnectedWallets)),
{ loading: () => (null) }
)
/**
* Root layout — all branding derived from Hanzo IAM org.
* Zero hardcoded brand. Tenant config fetched from IAM at render time.
*/
const RootLayout: React.FC<PropsWithChildren> = async ({ children }) => {
const settings = await getBridgeSettings()
// fetchTenant is safe during build (returns fallback when IAM unreachable)
let tenant
try {
tenant = await fetchTenant()
} catch {
tenant = (await import('@/lib/tenant')).getTenant()
}
return (
<html lang='en' suppressHydrationWarning className='dark' style={{backgroundColor: '#000'}}>
<head>
<base target='_blank' />
<title>{tenant.name}</title>
<meta name="description" content={`${tenant.name} — cross-chain bridge`} />
{tenant.faviconUrl && <link rel='icon' href={tenant.faviconUrl} />}
{/* Inject brand colors as CSS vars — drives all themed components */}
<style>{`
:root {
--brand-primary: ${tenant.primaryColor};
}
`}</style>
</head>
<body
suppressHydrationWarning
className={`${inter.variable} font-sans bg-background text-foreground flex flex-col min-h-full`}
>
{settings ? (
<Contexts settings={settings}>
<Header siteDef={siteDef} logoVariant='logo-only' logoSrc={tenant.logoUrl}>
<ConnectedWallets
connectButtonVariant='outline'
showWalletIcon={false}
connectButtonClx='pl-3 pr-2 flex rounded-lg relative'
>
<span className='pr-1.5'>Connect</span>
<span className='pr-1'>|</span>
<span className=''>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</span>
</ConnectedWallets >
</Header>
<Main>{children}</Main>
</Contexts>
) : (
<Main>
<div className="flex items-center justify-center min-h-screen">
<p className="text-muted-foreground">Loading...</p>
</div>
</Main>
)}
<footer className="py-4 text-center text-xs text-muted-foreground">
{tenant.name}
</footer>
</body>
</html>
)
}
export default RootLayout
-60
View File
@@ -1,60 +0,0 @@
import Link from "next/link"
import { cookies } from 'next/headers'
export default async function NotFoundComponent() {
const c = await cookies()
const token = c.get('auth-token')
const email = token?.value
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-black text-white font-inter">
<div className="mx-auto max-w-md text-center">
<h1 className="text-[96px] font-bold">404</h1>
{token ? <p className="mt-4 text-2xl">You are logged in as z@zeekay.io</p> : <p className="mt-4 text-2xl">This page does not exist</p>}
{
token
?
<Link
href="/login"
className="mt-6 inline-flex items-center rounded-md bg-white px-4 py-2 text-sm font-medium text-black shadow-sm transition-colors hover:bg-gray focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2"
prefetch={false}
>
Sign in as a different user
</Link>
:
<Link
href="/login"
className="mt-6 inline-flex items-center rounded-md bg-white px-4 py-2 text-sm font-medium text-black shadow-sm transition-colors hover:bg-gray focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2"
prefetch={false}
>
Sign in to you account
</Link>
}
</div>
<div className="flex flex-row gap-8 mt-10">
<Link
href="/docs"
className="no-underline text-base text-white"
prefetch={false}
>
Docs
</Link>
<Link
href="/guides"
className="no-underline text-base text-white"
prefetch={false}
>
Guides
</Link>
<Link
href="/blog"
className="no-underline text-base text-white"
prefetch={false}
>
Blog
</Link>
</div>
</div>
)
}
-10
View File
@@ -1,10 +0,0 @@
import React from 'react'
import Swapper from "@/components/Swapper"
export const dynamic = 'force-dynamic'
const Page: React.FC = () => (
<Swapper />
)
export default Page
-61
View File
@@ -1,61 +0,0 @@
'use client'
import React, { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { clearTempData, getTempData } from '@/lib/openLink'
export const dynamic = 'force-dynamic'
const Salon: React.FC = () => {
const router = useRouter()
useEffect(() => {
const temp_data = getTempData()
if (!temp_data) return
if (temp_data.swap_id) {
clearTempData()
const params = new URLSearchParams((temp_data?.query ?? {}))
params.set('coinbase_redirect', 'true')
router.push(`/swap/${temp_data.swap_id}${params.size ? `?${params.toString()}` : ''}`)
}
else {
const params = new URLSearchParams((temp_data?.query ?? {}))
params.set('coinbase_redirect', 'true')
router.push(`/${params.size ? `?${params.toString()}` : ''}`)
}
}, [])
return (
<div className='h-full min-h-screen flex flex-col justify-center text-md font-lighter leading-6'>
<div className='flex place-content-center mb-4'>
<svg
xmlns='http://www.w3.org/2000/svg'
width='140'
height='140'
viewBox='0 0 116 116'
fill='none'
>
<circle cx='58' cy='58' r='58' fill='#55B585' fillOpacity='0.1' />
<circle cx='58' cy='58' r='45' fill='#55B585' fillOpacity='0.3' />
<circle cx='58' cy='58' r='30' fill='#55B585' />
<path
d='M44.5781 57.245L53.7516 66.6843L70.6308 49.3159'
stroke='white'
strokeWidth='3.15789'
strokeLinecap='round'
/>
</svg>
</div>
<div className='flex flex-col text-center place-content-center space-y-2'>
<p className='font-bold text-lg'>
{' '}
Exchange account successfully connected{' '}
</p>
<p> You can close this window now</p>
</div>
</div>
)
}
export default Salon
@@ -1,12 +0,0 @@
import React from 'react'
import SwapProcess from '@/components/lux/teleport/process'
export const dynamic = 'force-dynamic'
const SwapDetails: React.FC<{
params: { swapId: string }
}> = ({ params }) => {
return <SwapProcess swapId={params.swapId} className="mt-20" />
}
export default SwapDetails
@@ -1,12 +0,0 @@
import React from 'react'
import SwapProcess from '@/components/lux/utila/process'
export const dynamic = 'force-dynamic'
const SwapDetails: React.FC<{
params: { swapId: string }
}> = ({ params }) => {
return <SwapProcess swapId={params.swapId} className="mt-20" />
}
export default SwapDetails
-12
View File
@@ -1,12 +0,0 @@
import TransfersWrapper from '@/components/SwapHistory/TransfersWrapper'
import { SwapDataProvider } from '@/context/swap'
export const dynamic = 'force-dynamic'
const Transactions: React.FC = () => (
<SwapDataProvider>
<TransfersWrapper />
</SwapDataProvider>
)
export default Transactions
+16 -34
View File
@@ -1,10 +1,8 @@
'use client'
import React, { createContext, useContext, useEffect, useState } from 'react'
import type { ResolvedTenant } from '@/lib/tenant'
import { fetchTenant, getTenant, type TenantConfig } from '@/lib/tenant'
interface TenantContextValue {
tenant: ResolvedTenant | null
tenant: TenantConfig | null
loading: boolean
}
@@ -14,49 +12,33 @@ const TenantContext = createContext<TenantContextValue>({
})
export function TenantProvider({ children }: { children: React.ReactNode }) {
const [tenant, setTenant] = useState<ResolvedTenant | null>(null)
const [tenant, setTenant] = useState<TenantConfig | null>(() => getTenant())
const [loading, setLoading] = useState(true)
useEffect(() => {
const hostname = window.location.hostname
fetch(`/api/tenant?hostname=${encodeURIComponent(hostname)}`)
.then((r) => r.json())
let cancelled = false
fetchTenant()
.then((data) => {
if (cancelled) return
setTenant(data)
// Apply CSS custom properties for white-label theming
if (data.primaryColor) {
document.documentElement.style.setProperty('--brand-primary', data.primaryColor)
}
if (data.accentColor) {
document.documentElement.style.setProperty('--brand-accent', data.accentColor)
}
})
.catch(() => { /* use defaults */ })
.finally(() => setLoading(false))
.catch(() => {
/* use fallback */
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
return (
<TenantContext.Provider value={{ tenant, loading }}>
{children}
</TenantContext.Provider>
)
return <TenantContext.Provider value={{ tenant, loading }}>{children}</TenantContext.Provider>
}
export function useTenant() {
return useContext(TenantContext)
}
/** Server-side tenant resolution helper for layout.tsx */
export async function fetchTenantServer(hostname: string): Promise<ResolvedTenant | null> {
try {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'
const res = await fetch(
`${baseUrl}/api/tenant?hostname=${encodeURIComponent(hostname)}`,
{ next: { revalidate: 60 } }
)
if (!res.ok) return null
return res.json()
} catch {
return null
}
}
+39 -30
View File
@@ -1,15 +1,12 @@
/**
* Tenant resolution via Hanzo IAM organization.
*
* The IAM org IS the tenant. Each deployment sets IAM_HOST + IAM_ORG env vars.
* The bridge fetches org config (name, logo, colors) from IAM at startup.
* Login is standard OIDC via IAM. JWT org claim drives everything downstream.
*
* This is the same pattern used by:
* - ~/work/lux/exchange (VITE_IAM_HOST + VITE_IAM_ORG)
* - ~/work/hanzo/kms (SITE_NAME from IAM org)
* - ~/work/liquidity/app/apps/cex (VITE_IAM_HOST + VITE_IAM_ORG)
* The IAM org IS the tenant. Each deployment sets VITE_IAM_HOST + VITE_IAM_ORG
* env vars at build time. The bridge fetches org config (name, logo, colors)
* from IAM at runtime. Login is standard OIDC via IAM. JWT org claim drives
* everything downstream.
*
* Same pattern as ~/work/lux/exchange and ~/work/liquidity/app/apps/cex.
* Zero hardcoded brand. Zero tenant registry. IAM is the single source of truth.
*/
@@ -50,43 +47,58 @@ export interface TenantConfig {
primaryColor: string
/** Website URL from IAM org.websiteUrl */
websiteUrl: string
/** MPC mode — from BRIDGE_MPC_MODE env (default: custom) */
/** MPC mode — from VITE_BRIDGE_MPC_MODE env (default: custom) */
mpcMode: MpcMode
/** MPC endpoint — from MPC_ENDPOINT env */
/** MPC endpoint — from VITE_MPC_ENDPOINT env */
mpcEndpoint: string
/** Network — from BRIDGE_NETWORK env (default: mainnet) */
/** Network — from VITE_BRIDGE_NETWORK env (default: mainnet) */
network: 'mainnet' | 'testnet'
/** Allowed chains — from BRIDGE_ALLOWED_CHAINS env (comma-sep, or empty = all) */
/** Allowed chains — from VITE_BRIDGE_ALLOWED_CHAINS env (comma-sep, or empty = all) */
allowedChains?: string[]
/** IAM OIDC issuer URL */
iamHost: string
/** IAM OIDC client ID */
clientId: string
/** Explorer URL — from BRIDGE_EXPLORER_URL env */
/** Explorer URL — from VITE_BRIDGE_EXPLORER_URL env */
explorerUrl: string
/** Docs URL — from BRIDGE_DOCS_URL env */
/** Docs URL — from VITE_BRIDGE_DOCS_URL env */
docsUrl: string
}
// ── Environment config ───────────────────────────────────────────────
// ── Environment config (Vite-style, with safe fallback to globalThis.process) ──
const IAM_HOST = process.env.NEXT_PUBLIC_IAM_HOST || process.env.IAM_HOST || ''
const IAM_ORG = process.env.NEXT_PUBLIC_IAM_ORG || process.env.IAM_ORG || ''
const CLIENT_ID = process.env.NEXT_PUBLIC_CLIENT_ID || process.env.IAM_CLIENT_ID || ''
const MPC_ENDPOINT = process.env.MPC_ENDPOINT || ''
const MPC_MODE = (process.env.BRIDGE_MPC_MODE || 'custom') as MpcMode
const NETWORK = (process.env.BRIDGE_NETWORK || 'mainnet') as 'mainnet' | 'testnet'
const ALLOWED_CHAINS = process.env.BRIDGE_ALLOWED_CHAINS
? process.env.BRIDGE_ALLOWED_CHAINS.split(',').map((s) => s.trim())
// Vite exposes env vars under import.meta.env. Some deps still reach for
// process.env at module-init; vite.config.ts defines `process.env` as `{}`
// so we can safely reference globalThis.process.env with ?? fallbacks.
const env = ((import.meta as unknown as { env?: Record<string, string | undefined> }).env) ?? {}
function e(...keys: string[]): string {
for (const k of keys) {
const v = env[k]
if (v) return v
}
return ''
}
const IAM_HOST = e('VITE_IAM_HOST', 'VITE_NEXT_PUBLIC_IAM_HOST')
const IAM_ORG = e('VITE_IAM_ORG', 'VITE_NEXT_PUBLIC_IAM_ORG')
const CLIENT_ID = e('VITE_IAM_CLIENT_ID', 'VITE_NEXT_PUBLIC_CLIENT_ID')
const MPC_ENDPOINT = e('VITE_MPC_ENDPOINT')
const MPC_MODE = (e('VITE_BRIDGE_MPC_MODE') || 'custom') as MpcMode
const NETWORK = (e('VITE_BRIDGE_NETWORK') || 'mainnet') as 'mainnet' | 'testnet'
const ALLOWED_CHAINS_RAW = e('VITE_BRIDGE_ALLOWED_CHAINS')
const ALLOWED_CHAINS = ALLOWED_CHAINS_RAW
? ALLOWED_CHAINS_RAW.split(',').map((s) => s.trim()).filter(Boolean)
: undefined
const EXPLORER_URL = process.env.BRIDGE_EXPLORER_URL || ''
const DOCS_URL = process.env.BRIDGE_DOCS_URL || ''
const EXPLORER_URL = e('VITE_BRIDGE_EXPLORER_URL')
const DOCS_URL = e('VITE_BRIDGE_DOCS_URL')
const BRAND_NAME = e('VITE_BRAND_NAME', 'VITE_NEXT_PUBLIC_BRAND_NAME') || 'Bridge'
// ── Fallback (used when IAM is unreachable) ──────────────────────────
const FALLBACK_TENANT: TenantConfig = {
orgName: IAM_ORG || 'bridge',
name: process.env.NEXT_PUBLIC_BRAND_NAME || 'Bridge',
name: BRAND_NAME,
logoUrl: '/assets/img/logo.svg',
logoDarkUrl: '/assets/img/logo-dark.svg',
faviconUrl: '/favicon.ico',
@@ -116,7 +128,6 @@ export async function fetchTenant(): Promise<TenantConfig> {
if (cachedTenant && now < cacheExpiry) return cachedTenant
if (!IAM_HOST || !IAM_ORG) {
// No IAM configured — use fallback (env-var driven brand)
cachedTenant = FALLBACK_TENANT
cacheExpiry = now + 300_000
return cachedTenant
@@ -126,7 +137,6 @@ export async function fetchTenant(): Promise<TenantConfig> {
const url = `${IAM_HOST.replace(/\/+$/, '')}/api/get-organization?id=admin/${IAM_ORG}`
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
next: { revalidate: 300 }, // ISR: revalidate every 5 min
})
if (!res.ok) throw new Error(`IAM returned ${res.status}`)
@@ -153,11 +163,10 @@ export async function fetchTenant(): Promise<TenantConfig> {
}
cacheExpiry = now + 300_000
return cachedTenant
} catch (err) {
console.error('[tenant] Failed to fetch org from IAM, using fallback:', err)
cachedTenant = FALLBACK_TENANT
cacheExpiry = now + 60_000 // retry sooner on failure
cacheExpiry = now + 60_000
return cachedTenant
}
}
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import { fetchTenant } from '@/lib/tenant'
import './styles.css'
const root = document.getElementById('root')
if (!root) throw new Error('root element missing')
// Kick off tenant fetch — components use getTenant() to read the cached value.
// Fire-and-forget; UI renders immediately with the fallback and updates when
// the IAM response arrives (components are expected to subscribe via a hook
// or re-render naturally). For now we just prefetch.
void fetchTenant().then((t) => {
document.title = t.name || document.title
if (t.faviconUrl) {
const link = document.querySelector<HTMLLinkElement>("link[rel='icon']")
if (link) link.href = t.faviconUrl
}
document.documentElement.style.setProperty('--brand-primary', t.primaryColor)
})
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)
-23
View File
@@ -1,23 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
export async function middleware(req: NextRequest) {
const origin = req.headers.get("origin") || "";
const hostname = req.headers.get("host") || "localhost:3000";
const response = NextResponse.next();
// CORS
response.headers.set("Access-Control-Allow-Origin", origin);
response.headers.set("Access-Control-Allow-Methods", "GET,HEAD,POST,PUT,DELETE");
response.headers.set("Access-Control-Allow-Headers", "Content-Type");
response.headers.set("Access-Control-Allow-Credentials", "true");
// Propagate tenant hostname for SSR + API routes
response.headers.set("x-tenant-hostname", hostname);
return response;
}
export const config = {
matcher: ["/api/:path*", "/((?!_next/static|_next/image|favicon.ico).*)"],
};
+11
View File
@@ -0,0 +1,11 @@
import { lazy, Suspense } from 'react'
const AuthContent = lazy(() => import('@/components/AuthContent'))
export default function AuthPage() {
return (
<Suspense fallback={null}>
<AuthContent />
</Suspense>
)
}
@@ -0,0 +1,5 @@
import CampaignDetails from '@/components/Campaigns/Details'
export default function CampaignDetailPage({ params }: { params: { campaign: string } }) {
return <CampaignDetails campaign={params.campaign} />
}
+5
View File
@@ -0,0 +1,5 @@
import Campaigns from '@/components/Campaigns'
export default function CampaignsPage() {
return <Campaigns />
}
+5
View File
@@ -0,0 +1,5 @@
import Swapper from '@/components/Swapper'
export default function HomePage() {
return <Swapper />
}
+18
View File
@@ -0,0 +1,18 @@
import { Link } from 'wouter'
export default function NotFoundPage() {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-black text-white font-inter">
<div className="mx-auto max-w-md text-center">
<h1 className="text-[96px] font-bold">404</h1>
<p className="mt-4 text-2xl">This page does not exist</p>
<Link
href="/"
className="mt-6 inline-flex items-center rounded-md bg-white px-4 py-2 text-sm font-medium text-black shadow-sm transition-colors hover:bg-gray focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2"
>
Go home
</Link>
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react'
import { useLocation } from 'wouter'
import { clearTempData, getTempData } from '@/lib/openLink'
export default function SalonPage() {
const [, setLocation] = useLocation()
useEffect(() => {
const temp_data = getTempData()
if (!temp_data) return
if (temp_data.swap_id) {
clearTempData()
const params = new URLSearchParams(temp_data?.query ?? {})
params.set('coinbase_redirect', 'true')
setLocation(`/swap/${temp_data.swap_id}${params.size ? `?${params.toString()}` : ''}`)
} else {
const params = new URLSearchParams(temp_data?.query ?? {})
params.set('coinbase_redirect', 'true')
setLocation(`/${params.size ? `?${params.toString()}` : ''}`)
}
}, [setLocation])
return (
<div className="h-full min-h-screen flex flex-col justify-center text-md font-lighter leading-6">
<div className="flex place-content-center mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="140"
height="140"
viewBox="0 0 116 116"
fill="none"
>
<circle cx="58" cy="58" r="58" fill="#55B585" fillOpacity="0.1" />
<circle cx="58" cy="58" r="45" fill="#55B585" fillOpacity="0.3" />
<circle cx="58" cy="58" r="30" fill="#55B585" />
<path
d="M44.5781 57.245L53.7516 66.6843L70.6308 49.3159"
stroke="white"
strokeWidth="3.15789"
strokeLinecap="round"
/>
</svg>
</div>
<div className="flex flex-col text-center place-content-center space-y-2">
<p className="font-bold text-lg">Exchange account successfully connected</p>
<p>You can close this window now</p>
</div>
</div>
)
}
@@ -1,13 +1,8 @@
import React from 'react'
import SwapWithdrawal from '@/components/SwapWithdrawal'
import { SwapDataProvider } from '@/context/swap'
import { TimerProvider } from '@/context/timerContext'
export const dynamic = 'force-dynamic'
const SwapDetails: React.FC<{
params: { swapId: string }
}> = () => {
export default function SwapPage({ params: _params }: { params: { swapId: string } }) {
return (
<SwapDataProvider>
<TimerProvider>
@@ -16,5 +11,3 @@ const SwapDetails: React.FC<{
</SwapDataProvider>
)
}
export default SwapDetails
@@ -0,0 +1,5 @@
import SwapProcess from '@/components/lux/teleport/process'
export default function TeleporterSwapPage({ params }: { params: { swapId: string } }) {
return <SwapProcess swapId={params.swapId} className="mt-20" />
}
+10
View File
@@ -0,0 +1,10 @@
import TransfersWrapper from '@/components/SwapHistory/TransfersWrapper'
import { SwapDataProvider } from '@/context/swap'
export default function TransactionsPage() {
return (
<SwapDataProvider>
<TransfersWrapper />
</SwapDataProvider>
)
}
+5
View File
@@ -0,0 +1,5 @@
import SwapProcess from '@/components/lux/utila/process'
export default function UtilaSwapPage({ params }: { params: { swapId: string } }) {
return <SwapProcess swapId={params.swapId} className="mt-20" />
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Vite SPA shim for `next/dynamic`.
* React.lazy-backed dynamic imports with optional loading fallback.
*/
import React, { Suspense, type ComponentType, type FC } from 'react'
export interface DynamicOptions<P = Record<string, unknown>> {
loading?: FC<{ error?: Error | null; isLoading?: boolean; pastDelay?: boolean; retry?: () => void; timedOut?: boolean; _props?: P }>
ssr?: boolean
suspense?: boolean
}
type Loader<P> = () => Promise<{ default: ComponentType<P> } | ComponentType<P>>
export default function dynamic<P = Record<string, unknown>>(
loader: Loader<P>,
opts: DynamicOptions<P> = {},
): ComponentType<P> {
const Lazy = React.lazy(async () => {
const mod = await loader()
// Accept either { default: Component } or a Component directly
const Component = (mod as { default?: ComponentType<P> }).default ?? (mod as ComponentType<P>)
return { default: Component }
})
const Loading = opts.loading
const Wrapped: FC<P> = (props) => {
return React.createElement(
Suspense,
{ fallback: Loading ? React.createElement(Loading, {} as never) : null },
React.createElement(Lazy as ComponentType<P>, props),
)
}
Wrapped.displayName = 'DynamicShim'
return Wrapped
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Vite SPA shim for `next/font/google`.
* Returns a no-op Font descriptor — fonts loaded via @font-face in index.html.
*/
export interface FontDescriptor {
className: string
variable: string
style: { fontFamily: string }
}
function font(name: string): (_opts?: unknown) => FontDescriptor {
const cssVar = `--font-${name.toLowerCase().replace(/\s+/g, '-')}`
return () => ({
className: '',
variable: cssVar,
style: { fontFamily: name },
})
}
export const Inter = font('Inter')
export const Roboto = font('Roboto')
export const Open_Sans = font('Open Sans')
export const Lato = font('Lato')
export const Poppins = font('Poppins')
+40
View File
@@ -0,0 +1,40 @@
/**
* Vite SPA shim for `next/headers`. Browser-only; cookies come from document.cookie.
* No server context in SPA mode — headers() returns empty readable store.
*/
interface CookieStore {
get: (name: string) => { name: string; value: string } | undefined
getAll: () => Array<{ name: string; value: string }>
has: (name: string) => boolean
}
function parseCookies(): Map<string, string> {
const map = new Map<string, string>()
if (typeof document === 'undefined') return map
const raw = document.cookie
if (!raw) return map
for (const part of raw.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
const name = part.slice(0, idx).trim()
const value = decodeURIComponent(part.slice(idx + 1).trim())
if (name) map.set(name, value)
}
return map
}
export function cookies(): CookieStore {
const map = parseCookies()
return {
get: (name: string) => {
const value = map.get(name)
return value == null ? undefined : { name, value }
},
getAll: () => Array.from(map.entries()).map(([name, value]) => ({ name, value })),
has: (name: string) => map.has(name),
}
}
export function headers(): ReadonlyMap<string, string> {
return new Map()
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Vite SPA shim for `next/image`. Renders a plain <img>.
* Strips Next.js-specific props that would warn on <img>.
*/
import React, { type ImgHTMLAttributes } from 'react'
export interface StaticImageData {
src: string
width: number
height: number
blurDataURL?: string
}
export interface ImageProps
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'width' | 'height'> {
src: string | StaticImageData
alt: string
width?: number | string
height?: number | string
priority?: boolean
quality?: number
placeholder?: 'blur' | 'empty'
blurDataURL?: string
unoptimized?: boolean
fill?: boolean
sizes?: string
loader?: (args: { src: string; width: number; quality?: number }) => string
onLoadingComplete?: (img: HTMLImageElement) => void
}
const Image = React.forwardRef<HTMLImageElement, ImageProps>(function Image(
{ src, alt, width, height, priority, quality, placeholder, blurDataURL, unoptimized, fill, sizes, loader, onLoadingComplete, style, ...rest },
ref,
) {
const resolvedSrc = typeof src === 'string' ? src : src.src
const w = typeof width === 'number' ? width : width ? Number(width) : (typeof src !== 'string' ? src.width : undefined)
const h = typeof height === 'number' ? height : height ? Number(height) : (typeof src !== 'string' ? src.height : undefined)
const fillStyle: React.CSSProperties | undefined = fill
? { position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }
: undefined
return (
<img
ref={ref}
src={resolvedSrc}
alt={alt}
width={w}
height={h}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
sizes={sizes}
style={{ ...fillStyle, ...style }}
onLoad={onLoadingComplete ? (e) => onLoadingComplete(e.currentTarget) : undefined}
{...rest}
/>
)
})
export default Image
+57
View File
@@ -0,0 +1,57 @@
/**
* Vite SPA shim for `next/link`.
* Bridges Next.js <Link> API onto a plain <a>, using wouter for SPA navigation.
*/
import React, { type AnchorHTMLAttributes, type PropsWithChildren } from 'react'
import { Link as WouterLink } from 'wouter'
export interface LinkProps {
href: string | { pathname?: string | null; query?: Record<string, string> | string }
as?: string
replace?: boolean
scroll?: boolean
shallow?: boolean
passHref?: boolean
prefetch?: boolean
locale?: string | false
legacyBehavior?: boolean
}
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
LinkProps & { children?: React.ReactNode }
function resolveHref(href: LinkProps['href']): string {
if (typeof href === 'string') return href
const path = href?.pathname || '/'
if (!href?.query) return path
if (typeof href.query === 'string') {
return `${path}?${href.query}`
}
const qs = new URLSearchParams(href.query as Record<string, string>).toString()
return qs ? `${path}?${qs}` : path
}
const Link = React.forwardRef<HTMLAnchorElement, PropsWithChildren<Props>>(
function Link({ href, as: _as, replace, scroll, shallow, passHref, prefetch, locale, legacyBehavior, children, ...rest }, ref) {
const resolved = resolveHref(href)
const isExternal = /^https?:\/\//i.test(resolved) || resolved.startsWith('mailto:') || resolved.startsWith('tel:')
if (isExternal) {
return (
<a ref={ref} href={resolved} {...rest}>
{children}
</a>
)
}
return (
<WouterLink href={resolved} asChild>
<a ref={ref} {...rest}>
{children}
</a>
</WouterLink>
)
},
)
export default Link
+6
View File
@@ -0,0 +1,6 @@
/**
* Vite SPA shim for `@next/mdx` — not used at runtime in SPA mode.
*/
export default function withMDX<T = unknown>(config: T): T {
return config
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Vite SPA shim for `next/navigation`.
* Maps Next.js App Router navigation hooks onto wouter + URLSearchParams.
*/
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useLocation as useWouterLocation, useSearch } from 'wouter'
export type ReadonlyURLSearchParams = URLSearchParams
export function useSearchParams(): URLSearchParams {
const search = useSearch()
return useMemo(() => new URLSearchParams(search ?? ''), [search])
}
export function usePathname(): string {
const [location] = useWouterLocation()
return location
}
export interface AppRouter {
push: (href: string) => void
replace: (href: string) => void
back: () => void
forward: () => void
refresh: () => void
prefetch: (_href: string) => void
}
export function useRouter(): AppRouter {
const [, setLocation] = useWouterLocation()
return useMemo(
() => ({
push: (href: string) => setLocation(href),
replace: (href: string) => setLocation(href, { replace: true }),
back: () => window.history.back(),
forward: () => window.history.forward(),
refresh: () => window.location.reload(),
prefetch: () => {},
}),
[setLocation],
)
}
// Minimal useParams — wouter uses route-level params; callers that need [slug]
// should read via useRoute or the matched route. This shim returns {} unless
// the caller has set window.__NEXT_PARAMS__ via the page component.
function subscribe(cb: () => void) {
window.addEventListener('popstate', cb)
return () => window.removeEventListener('popstate', cb)
}
function getParams(): Record<string, string> {
return (window as unknown as { __NEXT_PARAMS__?: Record<string, string> }).__NEXT_PARAMS__ || {}
}
export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
return useSyncExternalStore(subscribe, getParams) as T
}
export function redirect(href: string): never {
window.location.assign(href)
throw new Error('redirect')
}
export function notFound(): never {
throw new Error('notFound')
}
+18
View File
@@ -0,0 +1,18 @@
/**
* Vite SPA shim for `next/server`.
* The SPA has no server runtime; these exports exist so any lingering
* module-level imports type-check and compile. Calling them throws.
*/
export class NextRequest extends Request {}
export const NextResponse = {
json: (_body: unknown, _init?: ResponseInit): Response => {
throw new Error('NextResponse.json is not available in the SPA')
},
redirect: (_url: string | URL, _status?: number): Response => {
throw new Error('NextResponse.redirect is not available in the SPA')
},
next: (): Response => {
throw new Error('NextResponse.next is not available in the SPA')
},
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Shim for `@sentry/nextjs` — forwards to `@sentry/browser` in the SPA.
* Provides the subset of API our code uses (captureException, configureScope,
* startTransaction). Avoids pulling the full Next.js integration.
*/
import * as Sentry from '@sentry/browser'
export const captureException = Sentry.captureException
export const configureScope = (cb: (scope: Sentry.Scope) => void) => {
const scope = Sentry.getCurrentScope()
cb(scope)
}
export const startTransaction = (_ctx: { name: string }) => ({
finish: () => {},
setData: (_k: string, _v: unknown) => {},
})
export default Sentry
+45
View File
@@ -0,0 +1,45 @@
/*
* Bridge — Vite SPA styles.
* Matches lux.cloud: #000 bg, rgba(255,255,255,0.08) borders, #f5f5f5 fg.
* Prismatic accent only for hero / brand moments.
*/
/* Existing Tailwind layer (kept — components reference @tailwind utilities) */
@import './styles/globals.css';
@import './styles/dialog-transition.css';
:root {
color-scheme: dark;
--bg: #000;
--panel: #000;
--fg: #f5f5f5;
--muted: rgba(245, 245, 245, 0.55);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.18);
--accent: #f5f5f5;
--accent-bg: #1a1a1a;
--brand-primary: #4f46e5;
}
html,
body,
#root {
min-height: 100%;
background: var(--bg);
color: var(--fg);
}
body {
font-family: 'Basel Grotesk', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-weight: 400;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
letter-spacing: -0.01em;
}
.prismatic {
background: linear-gradient(100deg, #a8a8ff 0%, #8bffd1 35%, #ffd18b 68%, #ff8bc0 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
+9 -8
View File
@@ -4,18 +4,19 @@ export default {
presets: [preset],
content: {
files: [
'src/**/*.tsx',
'index.html',
'src/**/*.{ts,tsx}',
'./node_modules/@luxfi/ui/**/*.{ts,tsx}',
'./node_modules/@hanzo/**/*.{ts,tsx}'
'./node_modules/@hanzo/**/*.{ts,tsx}',
],
},
theme: {
extend: {
colors: {
white: "#ffffff",
black: "#000000",
gray: "#e5e7eb",
}
white: '#ffffff',
black: '#000000',
gray: '#e5e7eb',
},
},
}
}
},
}
+19 -13
View File
@@ -1,19 +1,25 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [ "/node_modules" ],
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"allowJs": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"baseUrl": "src",
"paths": {
"@/*": ["*"],
"react": ["node_modules/@types/react"]
},
"plugins": [ { "name": "next" } ]
}
"@/*": ["*"]
}
},
"include": ["src", "vite.config.ts"],
"exclude": ["node_modules", "dist", ".next"]
}
+41
View File
@@ -0,0 +1,41 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// eslint-disable-next-line import/no-default-export
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'next/link': path.resolve(__dirname, 'src/shims/next-link.tsx'),
'next/navigation': path.resolve(__dirname, 'src/shims/next-navigation.ts'),
'next/image': path.resolve(__dirname, 'src/shims/next-image.tsx'),
'next/dynamic': path.resolve(__dirname, 'src/shims/next-dynamic.ts'),
'next/headers': path.resolve(__dirname, 'src/shims/next-headers.ts'),
'next/server': path.resolve(__dirname, 'src/shims/next-server.ts'),
'next/font/google': path.resolve(__dirname, 'src/shims/next-font-google.ts'),
'@next/mdx': path.resolve(__dirname, 'src/shims/next-mdx.ts'),
'@sentry/nextjs': path.resolve(__dirname, 'src/shims/sentry-nextjs.ts'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
sourcemap: false,
chunkSizeWarningLimit: 2_000,
},
server: {
port: 3000,
host: '0.0.0.0',
},
preview: {
port: 3000,
host: '0.0.0.0',
},
define: {
// Many downstream deps read process.env.* at runtime — polyfill to empty object.
'process.env': {},
},
})