Some checks failed
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 5s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 18s
CI / Detect changes (pull_request) Successful in 6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 15s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 42s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Failing after 1m36s
CI / Canvas (Next.js) (pull_request) Failing after 2m38s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Failing after 41s
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Failing after 1m39s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Failing after 1m40s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 11s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5m18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
CI / Platform (Go) (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 7s
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.
176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
"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=<name>]` 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<string | null>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
const dialogRef = useRef<HTMLDivElement>(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<HTMLButtonElement>("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(
|
|
<div
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
|
data-testid="purchase-success-modal"
|
|
>
|
|
{/* Backdrop — click closes, matches ConfirmDialog backdrop. */}
|
|
<div
|
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
|
onClick={() => setOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="purchase-success-title"
|
|
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
|
>
|
|
<div className="px-6 pt-6 pb-4">
|
|
<div className="flex items-start gap-4">
|
|
{/* 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. */}
|
|
<div
|
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full"
|
|
style={{
|
|
background:
|
|
"color-mix(in srgb, var(--color-good) 15%, transparent)",
|
|
color: "var(--color-good)",
|
|
}}
|
|
>
|
|
<svg
|
|
width="22"
|
|
height="22"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
/>
|
|
<path
|
|
d="M7.5 12.5L10.5 15.5L16.5 9.5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3
|
|
id="purchase-success-title"
|
|
className="text-base font-semibold text-ink"
|
|
>
|
|
Purchase successful
|
|
</h3>
|
|
<p className="mt-1.5 text-[13px] leading-relaxed text-ink-mid">
|
|
<span className="font-medium text-ink">{itemLabel}</span> has
|
|
been added to your workspace. Provisioning starts in the
|
|
background — you can keep working while it spins up.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
|
|
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-soft">
|
|
auto-dismiss · {AUTO_DISMISS_MS / 1000}s
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="px-3.5 py-1.5 text-[13px] rounded-lg bg-accent hover:bg-accent-strong text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|