diff --git a/showcase/app/api/embed-base/route.ts b/showcase/app/api/embed-base/route.ts
new file mode 100644
index 00000000..46987c49
--- /dev/null
+++ b/showcase/app/api/embed-base/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+
+// Serves the hosted-checkout origin (NVM_EMBED_BASE_URL) to the Fiat panel at
+// REQUEST time. It lives in a route handler on purpose: handlers are already
+// dynamic on Next 15 (force-dynamic is belt-and-braces), so this reflects the
+// pod's env, while the /t/[slug] pages are SSG and would freeze the value at
+// `next build`. "" (prod, unset) → the panel shows its "checkout not configured"
+// notice; dev falls back to the local embed on :4250.
+export const dynamic = "force-dynamic";
+
+export function GET() {
+ const embedBase =
+ process.env.NVM_EMBED_BASE_URL ??
+ (process.env.NODE_ENV === "production" ? "" : "http://localhost:4250");
+ // no-store so the Cloudflare edge in front of tutorials.nevermined.app can't
+ // cache one env value and re-freeze it — the very build-time freeze this fixes,
+ // just moved one hop out. force-dynamic governs Next, not the CDN.
+ return NextResponse.json({ embedBase }, { headers: { "Cache-Control": "no-store" } });
+}
diff --git a/showcase/app/t/[slug]/page.tsx b/showcase/app/t/[slug]/page.tsx
index da2aad39..8bf46730 100644
--- a/showcase/app/t/[slug]/page.tsx
+++ b/showcase/app/t/[slug]/page.tsx
@@ -188,16 +188,11 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
{t.run.kind === "live" ? (
) : t.run.kind === "fiat" ? (
- // Default to localhost only in dev. In production the var is required;
- // "" makes the panel show a "checkout not configured" notice rather than
- // silently pointing the iframe (and the origin check) at localhost.
-
+ // The panel fetches the hosted-checkout origin at runtime from
+ // GET /api/embed-base (a dynamic route reads NVM_EMBED_BASE_URL).
+ // This page is SSG, so reading the env here would freeze it at
+ // `next build` (unset → "" → a permanent "not configured" notice).
+
) : t.run.kind === "discover" ? (
) : (
diff --git a/showcase/components/FiatRunPanel.tsx b/showcase/components/FiatRunPanel.tsx
index 4f4f128d..ed27603a 100644
--- a/showcase/components/FiatRunPanel.tsx
+++ b/showcase/components/FiatRunPanel.tsx
@@ -15,6 +15,11 @@ const fmtUsd = (amountMinor: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(amountMinor / 100);
const ORDER_TIMEOUT_MS = 15_000;
+// Bound the embed-config read too: it's a same-origin JSON GET, so a hang means
+// the backend/CDN is wedged. Without this a never-settling fetch leaves embedBase
+// null forever → every package button stays disabled with no explanation. On
+// timeout we fall back to "" (the "not configured" notice), same as any failure.
+const EMBED_CFG_TIMEOUT_MS = 8_000;
type Item =
| { type: "msg"; role: "user" | "agent"; text: string }
@@ -22,10 +27,14 @@ type Item =
| { type: "confirm"; pkg: FiatPackage; paymentIntent: string }
| { type: "notice"; text: string };
-export default function FiatRunPanel({ run, embedBase }: { run: FiatRun; embedBase: string }) {
+export default function FiatRunPanel({ run }: { run: FiatRun }) {
const [items, setItems] = useState([{ type: "msg", role: "agent", text: run.greeting }]);
const [picking, setPicking] = useState(true);
const [busy, setBusy] = useState(false);
+ // The hosted-checkout origin is read at request time from GET /api/embed-base
+ // (a dynamic route), not baked into this SSG page at build. null = still
+ // loading; "" = configured-off (shows the notice); a URL = ready.
+ const [embedBase, setEmbedBase] = useState(null);
const logRef = useRef(null);
const confirmed = useRef>(new Set());
const orderPkg = useRef