From 1bc784b839828f2ae4ed21c88a1d92caa548ee1f Mon Sep 17 00:00:00 2001 From: Aitor <1726644+aaitor@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:06:13 +0200 Subject: [PATCH 1/5] feat(showcase): add "Fiat checkout" category + embedded Acme Travel Orders demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the fiat-checkout-chat tutorial in the showcase gallery the way the other tutorials appear (like the MPP demo): a new "Fiat checkout" category and a tutorial page with an embedded, interactive card-checkout panel. - New protocol "orders" (label "Fiat checkout") with a card glyph, placed after Catalog in the sidebar/index. - content/tutorials.ts: the fiat-checkout-chat entry — learn / how / code (the merchant backend + the verified postMessage listener) / files. - New FiatRun kind + FiatRunPanel: a self-contained, scripted Acme Travel chat (pick a trip → hosted card checkout → "booked"), reusing the existing runpanel CSS. Nothing charges a card in the deployed gallery; the real hosted Stripe iframe lives in the tutorial's own app (repoPath), noted in the panel. Build green (next build typechecks the content array); demo-agent self-check passes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8 --- showcase/app/globals.css | 1 + showcase/app/t/[slug]/page.tsx | 3 + showcase/components/AppShell.tsx | 2 + showcase/components/FiatRunPanel.tsx | 139 +++++++++++++++++++++++++++ showcase/components/icons.tsx | 6 ++ showcase/content/tutorials.ts | 103 ++++++++++++++++++++ showcase/lib/types.ts | 26 ++++- 7 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 showcase/components/FiatRunPanel.tsx diff --git a/showcase/app/globals.css b/showcase/app/globals.css index e1ce1190..a0da061d 100644 --- a/showcase/app/globals.css +++ b/showcase/app/globals.css @@ -109,6 +109,7 @@ button { font-family: inherit; } .sb-cat .glyph.x402 { background: linear-gradient(140deg, #12b3a6, #0b7d75); } .sb-cat .glyph.mcp { background: linear-gradient(140deg, #7c6bff, #5a49df); } .sb-cat .glyph.langchain { background: linear-gradient(140deg, #1ec46f, #0f9a52); } +.sb-cat .glyph.orders { background: linear-gradient(140deg, #4f7cff, #2f54eb); } .sb-cat .cat-count { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--faint); font-weight: 400; } .sb-cat .chev { color: var(--faint); transition: transform 0.18s ease; } diff --git a/showcase/app/t/[slug]/page.tsx b/showcase/app/t/[slug]/page.tsx index 3519adfb..a3feef50 100644 --- a/showcase/app/t/[slug]/page.tsx +++ b/showcase/app/t/[slug]/page.tsx @@ -7,6 +7,7 @@ import { repoUrl, repoFileUrl } from "@/lib/repo"; import LiveRunPanel from "@/components/LiveRunPanel"; import RecapPanel from "@/components/RecapPanel"; import DiscoverPanel from "@/components/DiscoverPanel"; +import FiatRunPanel from "@/components/FiatRunPanel"; import CodeBlock from "@/components/CodeBlock"; import { ArrowRight, ArrowLeft, GitHub, External } from "@/components/icons"; @@ -184,6 +185,8 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug: {t.run.kind === "live" ? ( + ) : t.run.kind === "fiat" ? ( + ) : t.run.kind === "discover" ? ( ) : ( diff --git a/showcase/components/AppShell.tsx b/showcase/components/AppShell.tsx index daf4c2ef..d13f8840 100644 --- a/showcase/components/AppShell.tsx +++ b/showcase/components/AppShell.tsx @@ -15,6 +15,7 @@ import { Signal, Plug, Layers, + Card, Book, Globe, Discord, @@ -48,6 +49,7 @@ const GLYPH: Record = { mpp: , mcp: , langchain: , + orders: , }; export default function AppShell({ diff --git a/showcase/components/FiatRunPanel.tsx b/showcase/components/FiatRunPanel.tsx new file mode 100644 index 00000000..29c5fa78 --- /dev/null +++ b/showcase/components/FiatRunPanel.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { FiatRun, FiatPackage } from "@/lib/types"; +import { ArrowRight } from "./icons"; + +// Self-contained, scripted card-checkout chat — the deployed gallery can't reach a +// running Orders API, so nothing here charges a card. The real hosted Stripe iframe +// lives in the tutorial's own app (see run.note / repoPath). This mirrors the shape +// of the live tutorials' panel (components/LiveRunPanel) using the same CSS. + +type Item = + | { type: "msg"; role: "user" | "agent"; text: string } + | { type: "pay"; pkg: FiatPackage; resolved?: boolean } + | { type: "settle"; text: string } + | { type: "confirm"; pkg: FiatPackage }; + +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); + const logRef = useRef(null); + + useEffect(() => { + logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); + }, [items]); + + function pick(pkg: FiatPackage) { + if (busy) return; + setPicking(false); + setItems((x) => [ + ...x, + { type: "msg", role: "user", text: `I'd like to book the ${pkg.name}.` }, + { + type: "msg", + role: "agent", + text: `Great choice! Here's your secure checkout for the ${pkg.name} (${pkg.amount}). Pay by card — no account needed.`, + }, + { type: "pay", pkg }, + ]); + } + + function pay(pkg: FiatPackage, idx: number) { + if (busy) return; + setBusy(true); + setItems((x) => x.map((it, i) => (i === idx ? ({ ...it, resolved: true } as Item) : it))); + // brief pause so the "processing" state reads as a real card round-trip + setTimeout(() => { + setItems((x) => [ + ...x, + { type: "settle", text: `paid · card ····4242 · ${pkg.amount}` }, + { type: "confirm", pkg }, + ]); + setBusy(false); + }, 900); + } + + function reset() { + setItems([{ type: "msg", role: "agent", text: run.greeting }]); + setPicking(true); + } + + return ( + <> +
+
+
+ +
+ {items.map((it, i) => { + if (it.type === "msg") { + return ( +
+ {it.text} +
+ ); + } + if (it.type === "pay") { + return ( +
+ CARD + + {it.pkg.amount} · {it.pkg.name} + + +
+ ); + } + if (it.type === "settle") { + return ( +
+ PAID + {it.text} +
+ ); + } + return ( +
+ ✅ Payment confirmed — your {it.pkg.name} is booked! You'll get an itinerary by + email shortly. +
+ ); + })} + {busy ? ( +
+ charging the card… +
+ ) : null} +
+ + {picking ? ( +
+ {run.packages.map((p) => ( + + ))} +
+ ) : ( +
+ +
+ )} +
+

+ Scripted checkout — no real card is charged here. The real hosted Stripe iframe (test card{" "} + 4242 4242 4242 4242) runs in the tutorial's own app. {run.note} +

+ + ); +} diff --git a/showcase/components/icons.tsx b/showcase/components/icons.tsx index 2e791f06..97b79847 100644 --- a/showcase/components/icons.tsx +++ b/showcase/components/icons.tsx @@ -112,6 +112,12 @@ export const Bolt = ({ size, className }: P) => ( ); + +export const Card = ({ size, className }: P) => ( + +); // Signal / broadcast — the MPP category glyph (matches the "signal" Connect button theme). export const Signal = ({ size, className }: P) => ( { note: "Connect your Nevermined sandbox account, then send a city — the buyer runs the real MPP handshake (payments.mpp.fetch) against the deployed agent's pay-as-you-go route.", }, }, + + // ─────────────────────────────── Fiat checkout — Orders ───────────────────── + { + slug: "fiat-checkout-chat", + title: "Pay a merchant by card — no account", + tagline: + "A shopper with no Nevermined account and no API key pays a merchant an arbitrary fiat amount by card via Stripe, in the browser — the Nevermined Orders flow. The merchant org creates the order server-side; the buyer just pays a hosted checkout embedded in the chat.", + protocol: "orders", + language: "ts", + tier: "live", + repoPath: "fiat-checkout-chat/", + learn: { + lead: "Take a fiat card payment from a buyer with no Nevermined account, no API key, no wallet.", + bullets: [ + "The organization creates a payable Order server-side with its API key — POST /api/v1/orders", + "The org key stays in the merchant backend; the browser only ever calls your own /api/orders", + "The buyer pays a hosted Stripe checkout embedded as an iframe — no login, no crypto", + "The chat trusts the iframe's nvm:success message only after checking event.origin and the envelope version", + ], + }, + how: { + paragraphs: [ + "The shopper picks a trip; the app calls its own backend, which calls the Nevermined Orders API with the organization's key and gets back an orderId (the price is looked up server-side, so a tampered client can't name its own amount). The chat mounts the hosted Stripe checkout for that order in an iframe. The buyer pays with a test card; the iframe postMessages nvm:success, the chat verifies event.origin and version === '1', and shows a booked confirmation. clientSecret is never forwarded to the browser — the hosted checkout fetches the order itself.", + ], + flow: [ + { label: "pick a trip", sub: '"book the Barcelona trip"' }, + { label: "POST /api/orders", sub: "backend → Orders API (org key)", emphasis: true }, + { label: "iframe checkout", sub: "hosted Stripe · no account" }, + { label: "nvm:success", sub: "origin + version verified → booked" }, + ], + }, + tech: { + stack: ["Nevermined Orders", "Stripe", "Next.js", "React 19", "TypeScript"], + samples: [], + groups: [ + { + title: "Merchant backend", + lead: "The only holder of the org key — a Next.js server route. The browser calls this, never the Orders API directly.", + samples: [ + { + caption: "src/app/api/orders/route.ts", + lang: "typescript", + code: `export async function POST(req: Request) { + const { packageId } = await req.json() + const pkg = getPackage(packageId) // price is OURS, not the client's + const res = await fetch(\`\${NVM_API_BASE_URL}/api/v1/orders\`, { + method: 'POST', + headers: { + Authorization: \`Bearer \${NVM_ORDER_API_KEY}\`, // secret — server only + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + amountMinor: pkg.amountMinor, currency: 'usd', description: pkg.name, + }), + }) + const { orderId } = await res.json() // clientSecret NOT forwarded + return Response.json({ orderId }) +}`, + }, + ], + }, + { + title: "Embed + verified success", + lead: "Mount the hosted checkout in an iframe; trust nvm:success only from the embed origin and only version 1.", + samples: [ + { + caption: "src/app/chat.tsx", + lang: "tsx", + code: `// the hosted Stripe checkout for this order, embedded inline + + +// the confirmation is gated on a verified message +window.addEventListener('message', (e) => { + if (e.origin !== embedBase) return // only the embed origin + if (e.data?.type !== 'nvm:success') return // only our event + if (e.data?.version !== '1') return // only the envelope we understand + showBooked(e.data.payload) // { orderId, paymentIntent } +})`, + }, + ], + }, + ], + files: [ + { path: "src/app/api/orders/route.ts", desc: "merchant backend — the only holder of the org key" }, + { path: "src/app/chat.tsx", desc: "the chat UI, the checkout iframe, and the verified postMessage listener" }, + { path: "src/lib/packages.ts", desc: "server-owned catalog — prices live here, not on the client" }, + ], + }, + run: { + kind: "fiat", + merchant: "Acme Travel", + greeting: + "Hi! I'm your Acme Travel concierge. Pick a trip and pay by card right here — no account, no login. Which one sounds good?", + packages: [ + { id: "barcelona", name: "Barcelona City Break", amount: "$3,437.95", blurb: "3 nights · flights + hotel", emoji: "🏖️" }, + { id: "tokyo", name: "Tokyo Explorer", amount: "$12,899.00", blurb: "7 nights · flights + ryokan", emoji: "🗼" }, + { id: "safari", name: "Kenya Safari", amount: "$8,750.00", blurb: "5 nights · all-inclusive lodge", emoji: "🦁" }, + ], + note: "It runs for real from the fiat-checkout-chat/ app against a local Nevermined Orders stack (the Orders feature is in progress — epic #3238 — so the API + hosted checkout run from the feature branch until it ships).", + }, + }, ]; export function getTutorial(slug: string): Tutorial | undefined { @@ -930,6 +1032,7 @@ export function getTutorial(slug: string): Tutorial | undefined { // Sidebar / index grouping — fixed group order, items keep content-array order. export const GROUP_ORDER: { label: string; protocol: Protocol }[] = [ { label: "Catalog", protocol: "catalog" }, + { label: "Fiat checkout", protocol: "orders" }, { label: "x402 HTTP", protocol: "x402" }, { label: "MPP", protocol: "mpp" }, { label: "MCP", protocol: "mcp" }, diff --git a/showcase/lib/types.ts b/showcase/lib/types.ts index 6c49bb8b..e2f6d371 100644 --- a/showcase/lib/types.ts +++ b/showcase/lib/types.ts @@ -1,7 +1,7 @@ // Normalized content model shared by every tutorial page. // One shape for all tutorials → uniform pages out of wildly different READMEs. -export type Protocol = "x402" | "mpp" | "mcp" | "langchain" | "catalog"; +export type Protocol = "x402" | "mpp" | "mcp" | "langchain" | "catalog" | "orders"; export type Language = "ts" | "py" | "autonomous" | "agnostic"; export type Tier = "live" | "recap" | "discover"; @@ -126,6 +126,27 @@ export interface DiscoverRun { note: string; } +/** Section 4 (fiat) — an embedded, scripted card-checkout chat. Unlike `live` (which + * drives /api/agent), this is self-contained on the client: the panel (components/ + * FiatRunPanel) walks the Nevermined Orders flow — pick a package → hosted card + * checkout → "booked" — with no real charge. The real Stripe iframe lives in the + * tutorial's own app (repoPath), which needs the local Orders stack to run. */ +export interface FiatPackage { + id: string; + name: string; + /** display price, e.g. "$3,437.95" */ + amount: string; + blurb: string; + emoji: string; +} +export interface FiatRun { + kind: "fiat"; + merchant: string; + greeting: string; + packages: FiatPackage[]; + note: string; +} + export interface Tutorial { slug: string; title: string; @@ -138,7 +159,7 @@ export interface Tutorial { learn: LearnSection; how: HowSection; tech: TechSection; - run: LiveRun | RecapRun | DiscoverRun; + run: LiveRun | RecapRun | DiscoverRun | FiatRun; } export const PROTOCOL_LABEL: Record = { @@ -147,6 +168,7 @@ export const PROTOCOL_LABEL: Record = { mcp: "MCP", langchain: "LangChain", catalog: "Catalog", + orders: "Fiat checkout", }; export const LANGUAGE_LABEL: Record = { From 2383cf85316d49d3dadf3d709049fcce9461bbc7 Mon Sep 17 00:00:00 2001 From: Aitor <1726644+aaitor@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:19:17 +0200 Subject: [PATCH 2/5] feat(showcase): make the Fiat checkout demo run the REAL Orders flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the scripted card step with the actual flow: selecting a trip sends a real POST /api/v1/orders to the backend and embeds the hosted Stripe checkout, finalized by paying a test card in the iframe. - New showcase server route app/api/orders/route.ts — the merchant backend that holds the org key (server-only), owns the prices, and calls the Orders API. - FiatRunPanel now POSTs /api/orders, mounts the hosted Stripe iframe, and shows "booked" only after a verified nvm:success (origin + version === '1'). Falls back to a clear notice when the Orders backend isn't configured/reachable. - embedBase passed from server env (NVM_EMBED_BASE_URL); .env.example added. Local dev points at the local stack; production points at the sandbox Orders API. Verified e2e through the gallery panel: pick trip → real order → embedded Stripe → pay 4242 → nvm:success (v1) → booked. Build green; demo-agent self-check passes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014PGfHZCGGTv9S3u2AxMkH8 --- showcase/.env.example | 21 +++++ showcase/app/api/orders/route.ts | 77 +++++++++++++++ showcase/app/globals.css | 5 + showcase/app/t/[slug]/page.tsx | 2 +- showcase/components/FiatRunPanel.tsx | 136 +++++++++++++++++---------- showcase/content/tutorials.ts | 2 +- 6 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 showcase/.env.example create mode 100644 showcase/app/api/orders/route.ts diff --git a/showcase/.env.example b/showcase/.env.example new file mode 100644 index 00000000..aa1bc9cc --- /dev/null +++ b/showcase/.env.example @@ -0,0 +1,21 @@ +# --------------------------------------------------------------------------- +# "Fiat checkout" demo (tutorial: fiat-checkout-chat) — SERVER-SIDE ONLY. +# The gallery's "See it run" panel makes a real POST /orders and embeds the +# hosted Stripe checkout. These power app/api/orders/route.ts; none is +# NEXT_PUBLIC_*, so the org key never reaches the browser. +# +# Leave them unset and the rest of the showcase works fine — only the Fiat +# checkout panel needs them (it shows a "start the Orders backend" notice +# otherwise). While Orders is unshipped (epic #3238) point these at a local +# stack; in production, at the sandbox Orders deployment. +# --------------------------------------------------------------------------- + +# The organization's Nevermined API key (SECRET). Authorizes creating orders. +NVM_ORDER_API_KEY= + +# The Orders API the merchant backend calls. +NVM_API_BASE_URL=http://localhost:3001 + +# Origin of the hosted checkout the panel iframes; also the value the panel's +# success listener checks event.origin against. +NVM_EMBED_BASE_URL=http://localhost:4250 diff --git a/showcase/app/api/orders/route.ts b/showcase/app/api/orders/route.ts new file mode 100644 index 00000000..7d40c4eb --- /dev/null +++ b/showcase/app/api/orders/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +// Merchant backend for the "Fiat checkout" showcase demo — the ONLY place the +// organization's Nevermined API key is used. The browser panel (FiatRunPanel) +// calls THIS route with just a packageId; we look up the price server-side and +// create the Order on the org's behalf. The key never reaches the browser bundle. +// +// Local dev points at the local Orders stack; in production it points at the +// sandbox Orders API. Configure via env (see .env.example): +// NVM_ORDER_API_KEY the org's (sandbox) API key — SECRET, server-only +// NVM_API_BASE_URL the Orders API base (default http://localhost:3001) + +const NVM_API_BASE_URL = process.env.NVM_API_BASE_URL ?? "http://localhost:3001"; +const NVM_ORDER_API_KEY = process.env.NVM_ORDER_API_KEY; + +// Server-owned prices (USD minor units). The client never names an amount, so it +// can't pay less. Display strings live in content/tutorials.ts (keep in sync). +const CATALOG: Record = { + barcelona: { amountMinor: 343795, description: "Barcelona City Break" }, + tokyo: { amountMinor: 1289900, description: "Tokyo Explorer" }, + safari: { amountMinor: 875000, description: "Kenya Safari" }, +}; + +// Fail-fast guard at import: every amount must be in the Orders API's window. +for (const [id, p] of Object.entries(CATALOG)) { + if (!Number.isInteger(p.amountMinor) || p.amountMinor < 100 || p.amountMinor > 99_999_999) { + throw new Error(`Fiat catalog "${id}" amountMinor out of range: ${p.amountMinor}`); + } +} + +export async function POST(req: Request) { + if (!NVM_ORDER_API_KEY) { + return NextResponse.json( + { error: "Server missing NVM_ORDER_API_KEY — set it to run the live demo (see .env.example)." }, + { status: 503 }, + ); + } + + let packageId: unknown; + try { + ({ packageId } = await req.json()); + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + if (typeof packageId !== "string" || !CATALOG[packageId]) { + return NextResponse.json({ error: `Unknown package: ${String(packageId)}` }, { status: 400 }); + } + + const pkg = CATALOG[packageId]; + const res = await fetch(`${NVM_API_BASE_URL}/api/v1/orders`, { + method: "POST", + headers: { + Authorization: `Bearer ${NVM_ORDER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + amountMinor: pkg.amountMinor, // price is OURS, never the client's + currency: "usd", + description: pkg.description, + buyerRef: "showcase-fiat-checkout", + }), + }); + + if (!res.ok) { + const detail = await res.text(); + console.error(`[showcase/orders] Nevermined API ${res.status}: ${detail}`); + return NextResponse.json( + { error: `Orders API returned ${res.status}. Is the Orders backend running?` }, + { status: 502 }, + ); + } + + // Only orderId is forwarded — the hosted checkout fetches the rest itself via + // the no-auth GET /orders/:id, so clientSecret never touches the browser. + const { orderId } = await res.json(); + return NextResponse.json({ orderId }); +} diff --git a/showcase/app/globals.css b/showcase/app/globals.css index a0da061d..ee499232 100644 --- a/showcase/app/globals.css +++ b/showcase/app/globals.css @@ -110,6 +110,11 @@ button { font-family: inherit; } .sb-cat .glyph.mcp { background: linear-gradient(140deg, #7c6bff, #5a49df); } .sb-cat .glyph.langchain { background: linear-gradient(140deg, #1ec46f, #0f9a52); } .sb-cat .glyph.orders { background: linear-gradient(140deg, #4f7cff, #2f54eb); } + +/* Fiat checkout — the embedded hosted Stripe iframe inside the run panel */ +.fiat-checkout { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--panel); } +.fiat-checkout .fiat-cap { padding: 9px 13px; font-size: 12px; color: var(--ink-soft); border-bottom: 1px solid var(--border); font-family: var(--mono); } +.fiat-checkout iframe { width: 100%; height: 600px; border: 0; display: block; } .sb-cat .cat-count { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--faint); font-weight: 400; } .sb-cat .chev { color: var(--faint); transition: transform 0.18s ease; } diff --git a/showcase/app/t/[slug]/page.tsx b/showcase/app/t/[slug]/page.tsx index a3feef50..a5c1e80d 100644 --- a/showcase/app/t/[slug]/page.tsx +++ b/showcase/app/t/[slug]/page.tsx @@ -186,7 +186,7 @@ export default async function TutorialPage({ params }: { params: Promise<{ slug: {t.run.kind === "live" ? ( ) : t.run.kind === "fiat" ? ( - + ) : t.run.kind === "discover" ? ( ) : ( diff --git a/showcase/components/FiatRunPanel.tsx b/showcase/components/FiatRunPanel.tsx index 29c5fa78..14fd28ca 100644 --- a/showcase/components/FiatRunPanel.tsx +++ b/showcase/components/FiatRunPanel.tsx @@ -2,57 +2,93 @@ import { useEffect, useRef, useState } from "react"; import type { FiatRun, FiatPackage } from "@/lib/types"; -import { ArrowRight } from "./icons"; -// Self-contained, scripted card-checkout chat — the deployed gallery can't reach a -// running Orders API, so nothing here charges a card. The real hosted Stripe iframe -// lives in the tutorial's own app (see run.note / repoPath). This mirrors the shape -// of the live tutorials' panel (components/LiveRunPanel) using the same CSS. +// The REAL Orders flow, embedded in the gallery: pick a trip → POST /api/orders +// (our server route holds the org key and calls the Nevermined Orders API) → +// mount the hosted Stripe checkout in an iframe → the iframe postMessages +// nvm:success and we show "booked". No account, no wallet — a card in the iframe. +// +// This needs a running Orders backend + NVM_ORDER_API_KEY (local stack now; the +// sandbox once Orders ships). If it's not reachable, the panel says so. type Item = | { type: "msg"; role: "user" | "agent"; text: string } - | { type: "pay"; pkg: FiatPackage; resolved?: boolean } - | { type: "settle"; text: string } - | { type: "confirm"; pkg: FiatPackage }; + | { type: "checkout"; orderId: string; pkg: FiatPackage } + | { type: "confirm"; pkg: FiatPackage; paymentIntent: string } + | { type: "notice"; text: string }; -export default function FiatRunPanel({ run }: { run: FiatRun }) { +export default function FiatRunPanel({ run, embedBase }: { run: FiatRun; embedBase: string }) { const [items, setItems] = useState([{ type: "msg", role: "agent", text: run.greeting }]); const [picking, setPicking] = useState(true); const [busy, setBusy] = useState(false); const logRef = useRef(null); + const confirmed = useRef>(new Set()); + const orderPkg = useRef>(new Map()); useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight }); }, [items]); - function pick(pkg: FiatPackage) { + // Trust nvm:success only from the embed origin, only our event, only version 1. + useEffect(() => { + function onMessage(e: MessageEvent) { + if (e.origin !== embedBase) return; + if (e.data?.type !== "nvm:success") return; + if (e.data?.version !== "1") return; + const { orderId, paymentIntent } = e.data.payload ?? {}; + if (!orderId || confirmed.current.has(orderId)) return; + const pkg = orderPkg.current.get(orderId); + if (!pkg) return; + confirmed.current.add(orderId); + // swap the (completed) checkout iframe for the confirmation + setItems((x) => [ + ...x.filter((it) => !(it.type === "checkout" && it.orderId === orderId)), + { type: "confirm", pkg, paymentIntent: paymentIntent ?? "" }, + ]); + } + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [embedBase]); + + async function pick(pkg: FiatPackage) { if (busy) return; + setBusy(true); setPicking(false); setItems((x) => [ ...x, { type: "msg", role: "user", text: `I'd like to book the ${pkg.name}.` }, - { - type: "msg", - role: "agent", - text: `Great choice! Here's your secure checkout for the ${pkg.name} (${pkg.amount}). Pay by card — no account needed.`, - }, - { type: "pay", pkg }, + { type: "msg", role: "agent", text: "Setting up your secure checkout…" }, ]); - } - - function pay(pkg: FiatPackage, idx: number) { - if (busy) return; - setBusy(true); - setItems((x) => x.map((it, i) => (i === idx ? ({ ...it, resolved: true } as Item) : it))); - // brief pause so the "processing" state reads as a real card round-trip - setTimeout(() => { + try { + const res = await fetch("/api/orders", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ packageId: pkg.id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `order failed (${res.status})`); + orderPkg.current.set(data.orderId, pkg); + setItems((x) => [ + ...x.filter((it) => !(it.type === "msg" && it.text === "Setting up your secure checkout…")), + { + type: "msg", + role: "agent", + text: `Here's your secure checkout for the ${pkg.name} (${pkg.amount}). Pay with the Stripe test card 4242 4242 4242 4242 — any future expiry / CVC / ZIP.`, + }, + { type: "checkout", orderId: data.orderId, pkg }, + ]); + } catch (err) { setItems((x) => [ - ...x, - { type: "settle", text: `paid · card ····4242 · ${pkg.amount}` }, - { type: "confirm", pkg }, + ...x.filter((it) => !(it.type === "msg" && it.text === "Setting up your secure checkout…")), + { + type: "notice", + text: `Couldn't reach the Orders backend (${err instanceof Error ? err.message : "error"}). Start the local Nevermined Orders stack and set NVM_ORDER_API_KEY — see the tutorial's README.`, + }, ]); + setPicking(true); + } finally { setBusy(false); - }, 900); + } } function reset() { @@ -78,38 +114,41 @@ export default function FiatRunPanel({ run }: { run: FiatRun }) { ); } - if (it.type === "pay") { + if (it.type === "checkout") { + const src = + `${embedBase}/checkout/order/${it.orderId}` + + `?parentOrigin=${encodeURIComponent(typeof window !== "undefined" ? window.location.origin : "")}`; return ( -
- CARD - - {it.pkg.amount} · {it.pkg.name} - - +
+
+ 🔒 Secure Stripe checkout · {it.pkg.name} · {it.pkg.amount} +
+