diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml
new file mode 100644
index 0000000..2f610ec
--- /dev/null
+++ b/.github/workflows/preview.yml
@@ -0,0 +1,120 @@
+name: Preview deployment
+
+on:
+ pull_request:
+ branches: [develop, main]
+
+concurrency:
+ group: preview-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+permissions:
+ pull-requests: write
+ contents: read
+
+jobs:
+ build-preview:
+ name: Build & deploy preview
+ runs-on: ubuntu-latest
+
+ env:
+ # Enable the MSW mock layer for this build.
+ NEXT_PUBLIC_PREVIEW_MODE: "1"
+ # Signal that this is a preview, not a real production build.
+ PREVIEW_BUILD: "1"
+ # Stub values so the build doesn't fail on missing env vars.
+ NEXT_PUBLIC_API_URL: "http://localhost:8000/api/v1"
+ NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE: ""
+ NEXT_PUBLIC_STELLAR_RPC_URL: ""
+ NEXT_PUBLIC_STELLAR_EXPLORER_URL: ""
+ NEXT_PUBLIC_MARKET_CORE_CONTRACT_ID: ""
+ NEXT_PUBLIC_TREASURY_CONTRACT_ID: ""
+ NEXT_PUBLIC_RESOLVER_CONTRACT_ID: ""
+ NEXT_PUBLIC_REASONING_REGISTRY_CONTRACT_ID: ""
+ NEXT_PUBLIC_USDC_CONTRACT_ID: ""
+ NEXT_PUBLIC_ADMIN_ADDRESSES: ""
+ NEXT_PUBLIC_DAILY_PASS_PRICE_USDC: "0.5"
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Safety check – mock must not ship to production
+ run: npx tsx scripts/check-preview-not-in-prod.ts
+
+ - name: Build (static export)
+ run: npx next build
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: preview-${{ github.event.pull_request.number }}
+ path: out/
+ retention-days: 7
+
+ # ── Optional: deploy to a static host ────────────────────────────────
+ # Uncomment and fill in when a static host (Cloudflare Pages, Netlify,
+ # Vercel, …) is configured. The deploy step should output a URL, which
+ # is then posted as a PR comment below.
+ #
+ # - name: Deploy to Cloudflare Pages
+ # id: deploy
+ # uses: cloudflare/pages-action@v1
+ # with:
+ # apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ # accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ # projectName: oracledesk-preview
+ # directory: out
+ # branch: pr-${{ github.event.pull_request.number }}
+
+ - name: Comment on PR
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const prNumber = context.payload.pull_request.number;
+ const sha = context.payload.pull_request.head.sha.slice(0, 7);
+
+ const body = [
+ '## Preview build',
+ '',
+ `Built from ${sha} with mock backend (MSW) and fake Stellar RPC.`,
+ '',
+ 'The build artifact is attached to this workflow run. ' +
+ 'To explore it locally: download, unzip and serve with `npx serve out/`.',
+ '',
+ '> **Note:** All data is mocked. No real transactions occur.',
+ ].join('\n');
+
+ // Find an existing comment and update it, or create a new one.
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ });
+
+ const existing = comments.find(
+ (c) => c.user?.type === 'Bot' && c.body?.startsWith('## Preview build'),
+ );
+
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ body,
+ });
+ }
diff --git a/app/layout.tsx b/app/layout.tsx
index 9783119..d652497 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -11,6 +11,7 @@ import { PwaRegistrar } from "@/components/pwa/pwa-registrar";
import { DebugMount } from "@/components/debug/debug-mount";
import { RpcDegradedBanner } from "@/components/debug/RpcPanel";
import { WebVitals } from "@/app/web-vitals";
+import { CommandPalette } from "@/components/command-palette/CommandPalette";
const geistSans = Geist({
subsets: ["latin"],
@@ -66,6 +67,7 @@ export default function RootLayout({
{children}
+
diff --git a/app/markets/page-client.tsx b/app/markets/page-client.tsx
index 8e3732b..4c882b7 100644
--- a/app/markets/page-client.tsx
+++ b/app/markets/page-client.tsx
@@ -1,7 +1,7 @@
"use client";
import { Icon } from "@/components/ui/icon";
-import React, { useState, useEffect, useMemo } from "react";
+import React, { useCallback, useState, useEffect, useMemo } from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { useBurstFeed } from "@/lib/realtime/useBurst";
import { formatFeedTime } from "@/lib/realtime/format";
@@ -348,14 +348,21 @@ export default function MarketsPageClient() {
[marketsData]
);
+ // Stable callback: avoids a new function identity on every render so the
+ // React Compiler (and manual memoization on FilterBar) can optimise correctly.
+ const handleSetStatus = useCallback(
+ (s: string) => setStatus(s as MarketStatus),
+ [],
+ );
+
return (
- setStatus(s as MarketStatus)}
- category={category}
- setCategory={setCategory}
+
{isLoading ? (
diff --git a/components/command-palette/CommandPalette.tsx b/components/command-palette/CommandPalette.tsx
new file mode 100644
index 0000000..566456d
--- /dev/null
+++ b/components/command-palette/CommandPalette.tsx
@@ -0,0 +1,351 @@
+"use client";
+
+/**
+ * Global command palette (issue #149).
+ *
+ * Keyboard shortcut: Ctrl/Cmd+K — opens from anywhere.
+ * Built on the WAI-ARIA combobox pattern so keyboard-only and screen-reader
+ * users can navigate, trade and manage their wallet without a mouse.
+ *
+ * Architecture
+ * ─────────────
+ * • A single
renders in app/layout.tsx, outside .
+ * • It is always mounted; the dialog element is hidden until the shortcut fires.
+ * • Actions (navigate, copy address, disconnect …) are defined as plain data;
+ * market/trace results are fetched lazily after ≥2 chars of input.
+ * • Every destructive/wallet action goes through the same hooks the pages use
+ * (useWalletActions, useWalletAddress) so the review UI and confirmation
+ * steps are identical.
+ */
+
+import React, {
+ useCallback,
+ useEffect,
+ useId,
+ useRef,
+ useState,
+} from "react";
+import { useRouter } from "next/navigation";
+import { useMarkets } from "@/lib/hooks/useMarkets";
+import { useTraces } from "@/lib/hooks/useTraces";
+import { useWalletActions, useWalletAddress } from "@/lib/stellar/WalletProvider";
+
+// ─── types ──────────────────────────────────────────────────────────────────
+
+interface CommandItem {
+ id: string;
+ label: string;
+ description?: string;
+ icon?: string;
+ group: "nav" | "wallet" | "market" | "trace";
+ onSelect: () => void | Promise;
+}
+
+// ─── static items ────────────────────────────────────────────────────────────
+
+function useStaticItems(
+ router: ReturnType,
+ address: string | null,
+ actions: ReturnType,
+): CommandItem[] {
+ return [
+ { id: "nav-markets", label: "Go to Markets", icon: "bar_chart", group: "nav", onSelect: () => router.push("/markets") },
+ { id: "nav-portfolio", label: "Go to Portfolio", icon: "account_balance_wallet", group: "nav", onSelect: () => router.push("/portfolio") },
+ { id: "nav-reasoning", label: "Go to Reasoning", icon: "psychology", group: "nav", onSelect: () => router.push("/reasoning") },
+ { id: "nav-stats", label: "Go to Stats", icon: "analytics", group: "nav", onSelect: () => router.push("/stats") },
+ { id: "nav-premium", label: "Go to Premium", icon: "workspace_premium", group: "nav", onSelect: () => router.push("/premium") },
+ { id: "nav-settings", label: "Go to Settings", icon: "settings", group: "nav", onSelect: () => router.push("/settings") },
+ ...(address
+ ? [
+ {
+ id: "wallet-copy",
+ label: "Copy wallet address",
+ description: address ? `${address.slice(0, 8)}…${address.slice(-4)}` : undefined,
+ icon: "content_copy",
+ group: "wallet" as const,
+ onSelect: () => void navigator.clipboard.writeText(address),
+ },
+ {
+ id: "wallet-disconnect",
+ label: "Disconnect wallet",
+ icon: "logout",
+ group: "wallet" as const,
+ onSelect: () => void actions.disconnect(),
+ },
+ ]
+ : [
+ {
+ id: "wallet-connect",
+ label: "Connect wallet",
+ icon: "account_balance_wallet",
+ group: "wallet" as const,
+ onSelect: () => void actions.openModal(),
+ },
+ ]),
+ ];
+}
+
+// ─── group labels ─────────────────────────────────────────────────────────────
+
+const GROUP_LABELS: Record = {
+ nav: "Navigation",
+ wallet: "Wallet",
+ market: "Markets",
+ trace: "Traces",
+};
+
+// ─── main component ───────────────────────────────────────────────────────────
+
+export function CommandPalette() {
+ const router = useRouter();
+ const { address } = useWalletAddress();
+ const actions = useWalletActions();
+
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const inputRef = useRef(null);
+ const listRef = useRef(null);
+
+ const listboxId = useId();
+ const inputId = useId();
+
+ // ── keyboard shortcut ──────────────────────────────────────────────────────
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ setOpen((prev) => !prev);
+ }
+ if (e.key === "Escape") setOpen(false);
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, []);
+
+ // focus input when opened
+ useEffect(() => {
+ if (open) {
+ setQuery("");
+ setActiveIndex(0);
+ requestAnimationFrame(() => inputRef.current?.focus());
+ }
+ }, [open]);
+
+ // ── static items (stable reference while address doesn't change) ───────────
+
+ const staticItems = useStaticItems(router, address, actions);
+
+ // ── lazy market / trace search ─────────────────────────────────────────────
+
+ const shouldSearch = query.length >= 2;
+
+ const { data: marketsData } = useMarkets(
+ shouldSearch ? { status: "ACTIVE", limit: 5 } : null,
+ );
+ const { data: tracesData } = useTraces(
+ shouldSearch ? { limit: 5 } : null,
+ );
+
+ const marketItems: CommandItem[] = (marketsData?.markets ?? [])
+ .filter((m) => m.question.toLowerCase().includes(query.toLowerCase()))
+ .slice(0, 5)
+ .map((m) => ({
+ id: `market-${m.id}`,
+ label: m.question,
+ description: m.category,
+ icon: "bar_chart",
+ group: "market" as const,
+ onSelect: () => router.push(m.onChainMarketId ? `/market?onChainId=${m.onChainMarketId}` : `/markets`),
+ }));
+
+ const traceItems: CommandItem[] = (tracesData?.traces ?? [])
+ .filter((t) => ("question" in t ? (t.question as string).toLowerCase().includes(query.toLowerCase()) : true))
+ .slice(0, 5)
+ .map((t) => ({
+ id: `trace-${t.id}`,
+ label: "question" in t ? (t.question as string) : t.id,
+ icon: "psychology",
+ group: "trace" as const,
+ onSelect: () => router.push(`/reasoning/verify?traceId=${t.id}`),
+ }));
+
+ // ── filter and group ───────────────────────────────────────────────────────
+
+ const allItems: CommandItem[] = [
+ ...staticItems.filter(
+ (item) =>
+ !query ||
+ item.label.toLowerCase().includes(query.toLowerCase()) ||
+ (item.description?.toLowerCase().includes(query.toLowerCase()) ?? false),
+ ),
+ ...marketItems,
+ ...traceItems,
+ ];
+
+ const grouped = allItems.reduce
+
{
+ if (!flags.previewModeEnabled) {
+ setReady(true);
+ return;
+ }
+
+ // Dynamic import keeps the MSW bundle out of the initial chunk.
+ void import("@/lib/mocks/browser").then(({ worker }) =>
+ worker.start({ onUnhandledRequest: "bypass" }).then(() => setReady(true)),
+ );
+ }, []);
+
+ if (!ready && flags.previewModeEnabled) {
+ // Show nothing until the worker is ready so the first fetches are intercepted.
+ return null;
+ }
+
+ return <>{children}>;
+}
+
+/**
+ * Safe conditional wrapper: renders children directly in production.
+ * Import this in layout.tsx so the preview component is never evaluated
+ * in a production build.
+ */
+export function MaybePreviewModeProvider({ children }: { children: React.ReactNode }) {
+ if (process.env.NEXT_PUBLIC_PREVIEW_MODE !== "1") {
+ return <>{children}>;
+ }
+ return
{children};
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index bff489a..8fce1f0 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,6 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
+// React Compiler lint plugin (issue #152). Install with:
+// npm install --save-dev eslint-plugin-react-compiler
+// When the package is not present the import resolves to null and the rules
+// block below is skipped, so the build does not break before the package is
+// added. Remove the null-check once the package is in package.json.
+let reactCompilerPlugin: import("eslint").ESLint.Plugin | null = null;
+try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ reactCompilerPlugin = require("eslint-plugin-react-compiler");
+} catch {
+ // package not yet installed — skip
+}
const eslintConfig = defineConfig([
...nextVitals,
@@ -16,6 +28,25 @@ const eslintConfig = defineConfig([
"contracts/**",
"lib/stellar/generated/**",
]),
+ // React Compiler rules: fail on any new Rules-of-React violation.
+ // Directories opted out of the compiler (see next.config.ts) are also
+ // excluded here so the lint and compile exclusions stay in sync.
+ ...(reactCompilerPlugin
+ ? [
+ {
+ plugins: { "react-compiler": reactCompilerPlugin },
+ files: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}"],
+ ignores: [
+ "app/page.tsx",
+ "app/copy-trade/**",
+ "lib/stellar/typed-call.ts",
+ ],
+ rules: {
+ "react-compiler/react-compiler": "error",
+ },
+ },
+ ]
+ : []),
{
files: ["app/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}", "e2e/**/*.{ts,tsx}"],
ignores: ["lib/stellar/typed-call.ts"],
diff --git a/lib/feature-flags/index.ts b/lib/feature-flags/index.ts
new file mode 100644
index 0000000..d504e2b
--- /dev/null
+++ b/lib/feature-flags/index.ts
@@ -0,0 +1,75 @@
+/**
+ * Typed feature-flag system (issue #150).
+ *
+ * Defaults are inlined from NEXT_PUBLIC_* env vars at build time so the
+ * server-rendered HTML and the first client render always agree (no hydration
+ * mismatch). An optional runtime JSON endpoint can override individual flags
+ * after boot; consumers call `applyRuntimeOverrides(json)` once they fetch it.
+ *
+ * Usage:
+ * import { flags } from "@/lib/feature-flags";
+ * if (flags.x402UnlockEnabled) { … }
+ */
+
+export interface FeatureFlags {
+ /** Per-trace x402 unlock flow. Requires NEXT_PUBLIC_X402_URL to be set. */
+ x402UnlockEnabled: boolean;
+ /** Card payment option on /premium. When false the option is hidden. */
+ cardPaymentsEnabled: boolean;
+ /** Show the admin contract console pages to ADMIN_ADDRESSES. */
+ adminContractConsoleEnabled: boolean;
+ /** Multisig XDR export on the /admin pages. */
+ multisigXdrExportEnabled: boolean;
+ /** Passkey wallet option in the connect flow. */
+ passkeyWalletsEnabled: boolean;
+ /**
+ * Preview / mock mode. When true the app runs against an MSW mock layer.
+ * Must be false (and is tree-shaken out) in production bundles.
+ * Enabled automatically when NEXT_PUBLIC_PREVIEW_MODE=1 is set at build time.
+ */
+ previewModeEnabled: boolean;
+}
+
+/**
+ * Build-time defaults. Each NEXT_PUBLIC_* name is written out in full so
+ * Next.js inlines the value (dynamic access like `process.env[key]` is not
+ * supported).
+ */
+const buildTimeDefaults: FeatureFlags = {
+ x402UnlockEnabled: Boolean(process.env.NEXT_PUBLIC_X402_URL),
+ cardPaymentsEnabled: process.env.NEXT_PUBLIC_FLAG_CARD_PAYMENTS === "1",
+ adminContractConsoleEnabled: process.env.NEXT_PUBLIC_FLAG_ADMIN_CONTRACT_CONSOLE === "1",
+ multisigXdrExportEnabled: process.env.NEXT_PUBLIC_FLAG_MULTISIG_XDR_EXPORT === "1",
+ passkeyWalletsEnabled: process.env.NEXT_PUBLIC_FLAG_PASSKEY_WALLETS === "1",
+ previewModeEnabled:
+ process.env.NEXT_PUBLIC_PREVIEW_MODE === "1" &&
+ process.env.NODE_ENV !== "production",
+};
+
+// Mutable at runtime so `applyRuntimeOverrides` can patch flags without
+// replacing the reference (consumers already hold it).
+export const flags: FeatureFlags = { ...buildTimeDefaults };
+
+/**
+ * Merge a partial override object fetched from an optional runtime endpoint.
+ * The server must return `{ "flagName": true|false, … }`.
+ * Unknown keys are ignored.
+ */
+export function applyRuntimeOverrides(overrides: Partial
>): void {
+ for (const key of Object.keys(flags) as (keyof FeatureFlags)[]) {
+ if (typeof overrides[key] === "boolean") {
+ // previewModeEnabled can never be turned on at runtime in production.
+ if (key === "previewModeEnabled" && process.env.NODE_ENV === "production") continue;
+ flags[key] = overrides[key] as boolean;
+ }
+ }
+}
+
+/**
+ * Kill-switch helper: returns `false` and logs a warning when a flag has been
+ * force-disabled by a runtime override that sets it to `false` after a
+ * build-time `true`.
+ */
+export function isEnabled(flag: keyof FeatureFlags): boolean {
+ return flags[flag];
+}
diff --git a/lib/mocks/browser.ts b/lib/mocks/browser.ts
new file mode 100644
index 0000000..48fb168
--- /dev/null
+++ b/lib/mocks/browser.ts
@@ -0,0 +1,12 @@
+/**
+ * MSW browser worker setup for preview mode (issue #151).
+ *
+ * Imported only when flags.previewModeEnabled is true.
+ * The dynamic import in PreviewModeProvider keeps this out of
+ * the initial bundle even when preview mode is enabled.
+ */
+
+import { setupWorker } from "msw/browser";
+import { handlers } from "./handlers";
+
+export const worker = setupWorker(...handlers);
diff --git a/lib/mocks/handlers.ts b/lib/mocks/handlers.ts
new file mode 100644
index 0000000..bdad14a
--- /dev/null
+++ b/lib/mocks/handlers.ts
@@ -0,0 +1,148 @@
+/**
+ * MSW request handlers for preview / mock mode (issue #151).
+ *
+ * These handlers intercept every fetch that the app makes and return canned
+ * responses that match the backend's `{ ok, data, error, meta }` envelope
+ * (documented in docs/api.md). They also stub the Soroban RPC JSON-RPC
+ * endpoint so on-chain reads work without a live node.
+ *
+ * This file is imported only when NEXT_PUBLIC_PREVIEW_MODE=1 at build time.
+ * The tree-shaker removes it entirely from production bundles because all
+ * import paths that reach it are guarded by `if (flags.previewModeEnabled)`.
+ */
+
+import { http, HttpResponse, delay } from "msw";
+
+// ─── helpers ─────────────────────────────────────────────────────────────────
+
+function ok(data: T, meta?: Record) {
+ return HttpResponse.json({ ok: true, data, error: null, meta: meta ?? null });
+}
+
+// ─── fixture data ─────────────────────────────────────────────────────────────
+
+const MOCK_MARKETS = [
+ {
+ id: "mock-market-1",
+ question: "Will BTC close above $100k by end of Q1 2026?",
+ category: "CRYPTO",
+ status: "ACTIVE",
+ onChainMarketId: "MOCK_CHAIN_ID_1",
+ yesProb: 0.62,
+ resolvedOutcome: null,
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30).toISOString(),
+ createdAt: new Date().toISOString(),
+ reasoningTraces: [],
+ },
+ {
+ id: "mock-market-2",
+ question: "Will the Fed cut rates in November 2025?",
+ category: "MACRO",
+ status: "ACTIVE",
+ onChainMarketId: "MOCK_CHAIN_ID_2",
+ yesProb: 0.45,
+ resolvedOutcome: null,
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 60).toISOString(),
+ createdAt: new Date().toISOString(),
+ reasoningTraces: [],
+ },
+];
+
+const MOCK_TRACES = [
+ {
+ id: "mock-trace-1",
+ question: "Analyzing BTC Q1 2026 outlook",
+ onChainTraceId: "MOCK_TRACE_CHAIN_1",
+ createdAt: new Date().toISOString(),
+ modelId: "claude-3-5-sonnet",
+ confidence: 0.75,
+ },
+];
+
+const MOCK_USER = {
+ address: "GDMOCKADDRESS000000000000000000000000000000000000000000",
+ premiumUntil: null,
+ role: "user",
+};
+
+// ─── handlers ─────────────────────────────────────────────────────────────────
+
+export const handlers = [
+ // Markets
+ http.get("*/api/v1/markets", async () => {
+ await delay(80);
+ return ok(MOCK_MARKETS, { total: MOCK_MARKETS.length, page: 1, limit: 24 });
+ }),
+
+ http.get("*/api/v1/markets/on-chain/:id", async ({ params }) => {
+ await delay(60);
+ const market = MOCK_MARKETS.find((m) => m.onChainMarketId === params.id);
+ if (!market) return HttpResponse.json({ ok: false, data: null, error: { code: "NOT_FOUND", message: "Market not found" }, meta: null }, { status: 404 });
+ return ok(market);
+ }),
+
+ // Traces
+ http.get("*/api/v1/traces", async () => {
+ await delay(80);
+ return ok(MOCK_TRACES, { total: MOCK_TRACES.length });
+ }),
+
+ http.get("*/api/v1/traces/:id", async ({ params }) => {
+ await delay(60);
+ const trace = MOCK_TRACES.find((t) => t.id === params.id);
+ if (!trace) return HttpResponse.json({ ok: false, data: null, error: { code: "NOT_FOUND", message: "Trace not found" }, meta: null }, { status: 404 });
+ return ok(trace);
+ }),
+
+ // Auth
+ http.get("*/api/v1/auth/challenge", async () => {
+ await delay(30);
+ return ok({ xdr: "MOCK_XDR_CHALLENGE_0000000000000000000000000000000000000000" });
+ }),
+
+ http.post("*/api/v1/auth/login", async () => {
+ await delay(60);
+ return ok({ token: "mock-jwt-token", walletAddress: MOCK_USER.address });
+ }),
+
+ // Portfolio / user
+ http.get("*/api/v1/users/me", async () => {
+ await delay(40);
+ return ok(MOCK_USER);
+ }),
+
+ http.get("*/api/v1/portfolio/positions", async () => {
+ await delay(60);
+ return ok([]);
+ }),
+
+ // Health
+ http.get("*/api/v1/health", async () => {
+ await delay(20);
+ return ok({ status: "ok", version: "mock" });
+ }),
+
+ // Soroban RPC (JSON-RPC POST)
+ http.post("*/soroban/rpc", async ({ request }) => {
+ await delay(40);
+ const body = await request.json() as { method?: string; id?: unknown };
+ const method = body?.method ?? "unknown";
+ const id = body?.id ?? 1;
+
+ // Return minimal valid responses for the methods the app uses.
+ if (method === "getLedgerEntries") {
+ return HttpResponse.json({ jsonrpc: "2.0", id, result: { entries: [], latestLedger: "999999" } });
+ }
+ if (method === "simulateTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id, result: { results: [{ xdr: "" }], cost: { cpuInsns: "0", memBytes: "0" }, latestLedger: "999999" } });
+ }
+ if (method === "sendTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id, result: { status: "PENDING", hash: "MOCK_TX_HASH_0000000000000000000000000000000000000000000000000000000000" } });
+ }
+ if (method === "getTransaction") {
+ return HttpResponse.json({ jsonrpc: "2.0", id, result: { status: "SUCCESS", ledger: 999999, createdAt: Math.floor(Date.now() / 1000) } });
+ }
+
+ return HttpResponse.json({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
+ }),
+];
diff --git a/next.config.ts b/next.config.ts
index fc93071..5e1b794 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,6 +1,38 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
+ /**
+ * React Compiler (issue #152).
+ *
+ * Enabled incrementally: it runs on every file but annotationOnly mode
+ * lets you opt specific directories out by adding a `// @no-memo` comment
+ * at the top of a file, or by listing them in `ignoredDirs` below.
+ *
+ * Directories known to have Rules-of-React violations (Math.random in
+ * render, setInterval in body) are excluded until they are cleaned up.
+ * Remove an entry here once the violations in that directory are fixed and
+ * the compiler's lint rule passes with no new errors.
+ *
+ * To verify the compiler is running: `NEXT_COMPILER_VERBOSE=1 next build`
+ * will log each component the compiler processes.
+ */
+ experimental: {
+ reactCompiler: {
+ compilationMode: "infer",
+ // Opt out problematic directories incrementally.
+ // Each entry is a path prefix relative to the project root.
+ sources: (filename: string) => {
+ const excluded = [
+ // app/page.tsx uses Math.random() + setInterval in render-adjacent code.
+ "app/page.tsx",
+ // copy-trade reads refs during render for slider geometry.
+ "app/copy-trade/",
+ ];
+ return !excluded.some((p) => filename.includes(p));
+ },
+ },
+ },
+
/**
* The seven demo-only routes retired by issue #49. Their UI moved to
* components/demo/ and renders only in the noindex design gallery at
diff --git a/scripts/check-preview-not-in-prod.ts b/scripts/check-preview-not-in-prod.ts
new file mode 100644
index 0000000..54e640d
--- /dev/null
+++ b/scripts/check-preview-not-in-prod.ts
@@ -0,0 +1,22 @@
+#!/usr/bin/env node
+/**
+ * Build-time check: abort if NEXT_PUBLIC_PREVIEW_MODE=1 and NODE_ENV=production.
+ *
+ * Run from the CI/CD pipeline before `next build` for production targets.
+ * Preview deployments (NODE_ENV defaults to "production" in Next.js builds)
+ * must set PREVIEW_BUILD=1 instead of NODE_ENV; the check here reads that.
+ */
+
+const isProductionDeployment =
+ process.env.NODE_ENV === "production" &&
+ process.env.PREVIEW_BUILD !== "1";
+
+if (isProductionDeployment && process.env.NEXT_PUBLIC_PREVIEW_MODE === "1") {
+ console.error(
+ "[ERROR] NEXT_PUBLIC_PREVIEW_MODE=1 is set but this is a production build.\n" +
+ " The mock layer must never ship to production. Aborting.",
+ );
+ process.exit(1);
+}
+
+console.log("[check-preview-not-in-prod] OK");