From a37a4a6e40becec51e4a3a274af38d9f4f8c0209 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 7 May 2026 08:32:35 -0700 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20demo=20Mock=20#1=20=E2=80=94=20?= =?UTF-8?q?purchase-success=20modal=20on=20URL=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Funding-demo Mock #1: when the canvas loads with `?purchase_success=1`, show a centred success modal in the warm-paper theme. Auto-dismisses after 5s; Close button + Esc + backdrop click also dismiss; URL params are stripped on first paint so a refresh after dismiss does not re-trigger. Mounted in `app/layout.tsx` (not `app/page.tsx`) so the modal persists across the canvas page-state transitions (loading → hydrated → error) without unmounting and losing its open-state. No real billing logic — the marketplace "Purchase" button on the landing page redirects here with the flag; this modal is the only thing the user sees of the "transaction". Local-verified end-to-end via playwright (5/5 tests pass): redirect URL shape, modal visibility, URL cleanup, close button, refresh-after- dismiss behaviour, 5s auto-dismiss. Pairs with the Purchase button added to landingpage Marketplace section. --- canvas/src/app/layout.tsx | 7 + .../src/components/PurchaseSuccessModal.tsx | 175 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 canvas/src/components/PurchaseSuccessModal.tsx 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, + ); +} -- 2.45.2