mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
+11
-68
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
-5
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': "postcss-nesting",
|
||||
'tailwindcss/nesting': 'postcss-nesting',
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
import Campaigns from '@/components/Campaigns'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const CampaignsPage: React.FC = () => (
|
||||
<Campaigns />
|
||||
)
|
||||
|
||||
export default CampaignsPage
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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).*)"],
|
||||
};
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Campaigns from '@/components/Campaigns'
|
||||
|
||||
export default function CampaignsPage() {
|
||||
return <Campaigns />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Swapper from '@/components/Swapper'
|
||||
|
||||
export default function HomePage() {
|
||||
return <Swapper />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import TransfersWrapper from '@/components/SwapHistory/TransfersWrapper'
|
||||
import { SwapDataProvider } from '@/context/swap'
|
||||
|
||||
export default function TransactionsPage() {
|
||||
return (
|
||||
<SwapDataProvider>
|
||||
<TransfersWrapper />
|
||||
</SwapDataProvider>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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': {},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user