diff --git a/canvas/src/app/layout.tsx b/canvas/src/app/layout.tsx index 1e2a28af..21ec7962 100644 --- a/canvas/src/app/layout.tsx +++ b/canvas/src/app/layout.tsx @@ -3,6 +3,7 @@ import { cookies, headers } from "next/headers"; import "./globals.css"; import { AuthGate } from "@/components/AuthGate"; import { CookieConsent } from "@/components/CookieConsent"; +import { PurchaseSuccessModal } from "@/components/PurchaseSuccessModal"; import { ThemeProvider } from "@/lib/theme-provider"; import { THEME_COOKIE, @@ -86,6 +87,12 @@ export default async function RootLayout({ vercel preview URL, apex) pass through unchanged. */} {children} + {/* Demo Mock #1: post-purchase success toast. Mounted at the + layout level so it persists across page state transitions + (loading → hydrated → error) without being unmounted and + losing its open-state. Reads ?purchase_success=1 from the + URL on first paint, then strips the param. */} + diff --git a/canvas/src/components/PurchaseSuccessModal.tsx b/canvas/src/components/PurchaseSuccessModal.tsx new file mode 100644 index 00000000..d9672a63 --- /dev/null +++ b/canvas/src/components/PurchaseSuccessModal.tsx @@ -0,0 +1,175 @@ +"use client"; + +/** + * PurchaseSuccessModal — demo-only post-purchase confirmation. + * + * Mounted on the canvas root (`app/page.tsx`). On first paint it inspects + * `?purchase_success=1[&item=]` on the current URL. If present, it + * renders a centred modal styled after `ConfirmDialog`, schedules a 5s + * auto-dismiss, and rewrites the URL via `history.replaceState` to drop + * the params so a refresh after dismiss does NOT re-show the modal. + * + * Mock for the funding demo — there is no real billing surface behind + * this. The marketplace "Purchase" button on the landing page redirects + * here with the params; this modal is the only thing the user sees of + * the "transaction". + * + * Styling matches the warm-paper @theme tokens (surface-sunken / line / + * ink / good) so it tracks light + dark without per-mode overrides. + */ + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +const AUTO_DISMISS_MS = 5000; + +function readPurchaseParams(): { open: boolean; item: string | null } { + if (typeof window === "undefined") return { open: false, item: null }; + const sp = new URLSearchParams(window.location.search); + const flag = sp.get("purchase_success"); + if (flag !== "1" && flag !== "true") return { open: false, item: null }; + return { open: true, item: sp.get("item") }; +} + +function stripPurchaseParams() { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + url.searchParams.delete("purchase_success"); + url.searchParams.delete("item"); + // replaceState (not pushState) so back-button doesn't return to the + // pre-strip URL and re-trigger the modal. + window.history.replaceState({}, "", url.toString()); +} + +export function PurchaseSuccessModal() { + const [open, setOpen] = useState(false); + const [item, setItem] = useState(null); + const [mounted, setMounted] = useState(false); + const dialogRef = useRef(null); + + // Read the URL params once on mount. We don't subscribe to navigation — + // this modal is a one-shot for the demo redirect, not a persistent + // listener. + useEffect(() => { + setMounted(true); + const { open: shouldOpen, item: itemName } = readPurchaseParams(); + if (shouldOpen) { + setOpen(true); + setItem(itemName); + // Clean the URL immediately so a refresh after the modal is closed + // (or even while it's still open) does NOT re-trigger it. + stripPurchaseParams(); + } + }, []); + + // Auto-dismiss timer + Escape handler. + useEffect(() => { + if (!open) return; + const t = window.setTimeout(() => setOpen(false), AUTO_DISMISS_MS); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + window.addEventListener("keydown", onKey); + // Focus the close button so keyboard users land on it after redirect. + const raf = requestAnimationFrame(() => { + dialogRef.current?.querySelector("button")?.focus(); + }); + return () => { + window.clearTimeout(t); + window.removeEventListener("keydown", onKey); + cancelAnimationFrame(raf); + }; + }, [open]); + + if (!open || !mounted) return null; + + const itemLabel = item ? decodeURIComponent(item) : "Your new agent"; + + return createPortal( +
+ {/* Backdrop — click closes, matches ConfirmDialog backdrop. */} +
setOpen(false)} + aria-hidden="true" + /> + +
+
+
+ {/* Success glyph — uses --color-good so it tracks the theme. + Inline SVG over an emoji so it stays readable + on-brand + in both light and dark. */} +
+ +
+
+

+ Purchase successful +

+

+ {itemLabel} has + been added to your workspace. Provisioning starts in the + background — you can keep working while it spins up. +

+
+
+
+ +
+ + auto-dismiss · {AUTO_DISMISS_MS / 1000}s + + +
+
+
, + document.body, + ); +}