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>(new Map()); @@ -47,6 +56,19 @@ export default function FiatRunPanel({ run, embedBase }: { run: FiatRun; embedBa logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); }, [items]); + // Fetch the embed origin once on mount; on any failure fall back to "" (the + // panel then shows the "not configured" notice rather than a broken iframe). + useEffect(() => { + let alive = true; + fetch("/api/embed-base", { signal: AbortSignal.timeout(EMBED_CFG_TIMEOUT_MS) }) + .then((r) => r.json()) + .then((d) => alive && setEmbedBase(typeof d?.embedBase === "string" ? d.embedBase : "")) + .catch(() => alive && setEmbedBase("")); + return () => { + alive = false; + }; + }, []); + // Trust nvm:success only from the embed origin, only our event, only version 1. useEffect(() => { function onMessage(e: MessageEvent) { @@ -186,8 +208,21 @@ export default function FiatRunPanel({ run, embedBase }: { run: FiatRun; embedBa {picking ? (
+ {/* embedBase === null → still fetching the checkout origin; the buttons + are disabled, so say why rather than showing an inert, silent row. */} + {embedBase === null ? ( + + connecting to secure checkout… + + ) : null} {run.packages.map((p) => ( - ))}