Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions showcase/app/api/embed-base/route.ts
Original file line number Diff line number Diff line change
@@ -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" } });
}
15 changes: 5 additions & 10 deletions showcase/app/t/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,11 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug:
{t.run.kind === "live" ? (
<LiveRunPanel slug={t.slug} run={t.run} title={t.title} />
) : 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.
<FiatRunPanel
run={t.run}
embedBase={
process.env.NVM_EMBED_BASE_URL ??
(process.env.NODE_ENV === "production" ? "" : "http://localhost:4250")
}
/>
// 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).
<FiatRunPanel run={t.run} />
) : t.run.kind === "discover" ? (
<DiscoverPanel run={t.run} />
) : (
Expand Down
39 changes: 37 additions & 2 deletions showcase/components/FiatRunPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,26 @@ 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 }
| { type: "checkout"; orderId: string; pkg: FiatPackage }
| { 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<Item[]>([{ 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<string | null>(null);
const logRef = useRef<HTMLDivElement>(null);
const confirmed = useRef<Set<string>>(new Set());
const orderPkg = useRef<Map<string, FiatPackage>>(new Map());
Expand All @@ -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) {
Expand Down Expand Up @@ -186,8 +208,21 @@ export default function FiatRunPanel({ run, embedBase }: { run: FiatRun; embedBa

{picking ? (
<div className="rp-suggest" style={{ flexWrap: "wrap" }}>
{/* embedBase === null → still fetching the checkout origin; the buttons
are disabled, so say why rather than showing an inert, silent row. */}
{embedBase === null ? (
<span className="working">
<span className="spinner" /> connecting to secure checkout…
</span>
) : null}
{run.packages.map((p) => (
<button key={p.id} className="schip" onClick={() => pick(p)} disabled={busy}>
<button
key={p.id}
className="schip"
onClick={() => pick(p)}
disabled={busy || embedBase === null}
title={embedBase === null ? "Connecting to secure checkout…" : undefined}
>
{p.emoji} {p.name} · {fmtUsd(p.amountMinor)}
</button>
))}
Expand Down
Loading