From 72061b130e813eb7de469c392203a3cf6596f50e Mon Sep 17 00:00:00 2001 From: broody Date: Wed, 25 Feb 2026 10:47:59 -1000 Subject: [PATCH 1/3] feat: embed coinbase payment in keychain popup with status polling Instead of opening the raw Coinbase payment link directly in the popup, open it embedded in the keychain app at /coinbase?paymentLink=...&orderId=... This ensures: - The popup top-level domain is x.cartridge.gg so the Coinbase iframe works - The popup page independently polls order status via GraphQL - Auto-closes on successful payment - Shows failure/timeout messages within the popup - The keychain also continues polling from its side --- packages/keychain/src/components/app.tsx | 2 + .../src/components/coinbase-popup.tsx | 145 ++++++++++++++++++ .../src/hooks/starterpack/coinbase.ts | 19 ++- 3 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 packages/keychain/src/components/coinbase-popup.tsx diff --git a/packages/keychain/src/components/app.tsx b/packages/keychain/src/components/app.tsx index e9e0a0f47..f98f9203b 100644 --- a/packages/keychain/src/components/app.tsx +++ b/packages/keychain/src/components/app.tsx @@ -69,6 +69,7 @@ import { hasApprovalPolicies } from "@/hooks/session"; import { PurchaseStarterpack } from "./purchasenew/starterpack/starterpack"; import { Quests } from "./quests"; import { QuestClaim } from "./quests/claim"; +import { CoinbasePopup } from "./coinbase-popup"; function DefaultRoute() { const account = useAccount(); @@ -233,6 +234,7 @@ export function App() { return ( } /> + } /> }> } /> } /> diff --git a/packages/keychain/src/components/coinbase-popup.tsx b/packages/keychain/src/components/coinbase-popup.tsx new file mode 100644 index 000000000..fc643c5b7 --- /dev/null +++ b/packages/keychain/src/components/coinbase-popup.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import { SpinnerIcon, TimesIcon } from "@cartridge/ui"; +import { + CoinbaseOnRampOrderDocument, + CoinbaseOnRampOrderQuery, + CoinbaseOnrampStatus, +} from "@cartridge/ui/utils/api/cartridge"; +import { request } from "@/utils/graphql"; + +/** Polling interval for checking order status (5 seconds) */ +const POLL_INTERVAL_MS = 5_000; +/** Timeout for the payment (10 minutes) */ +const PAYMENT_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Standalone page rendered at /coinbase in the keychain app. + * Opened as a popup from the CoinbaseCheckout component. + * + * - Embeds the Coinbase payment link in an iframe (works because + * the top-level domain is x.cartridge.gg) + * - Polls the order status via GraphQL + * - Closes the popup automatically on success + * - Shows failure message if payment fails or times out + */ +export function CoinbasePopup() { + const [searchParams] = useSearchParams(); + const paymentLink = searchParams.get("paymentLink"); + const orderId = searchParams.get("orderId"); + + const [iframeLoaded, setIframeLoaded] = useState(false); + const [status, setStatus] = useState(); + const [error, setError] = useState(); + + const pollIntervalRef = useRef | null>(null); + const pollTimeoutRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; + } + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + // Start polling when we have an orderId + useEffect(() => { + if (!orderId) return; + + const poll = async () => { + try { + const result = await request( + CoinbaseOnRampOrderDocument, + { orderId }, + ); + const orderStatus = result.coinbaseOnrampOrder.status; + setStatus(orderStatus); + + if (orderStatus === CoinbaseOnrampStatus.Completed) { + stopPolling(); + // Brief delay so the user sees the success state before auto-close + setTimeout(() => window.close(), 1500); + } else if (orderStatus === CoinbaseOnrampStatus.Failed) { + stopPolling(); + setError("Payment failed. You can close this window and try again."); + } + } catch (err) { + console.error("Failed to poll order status:", err); + // Don't stop polling on transient errors + } + }; + + // Immediate first poll + poll(); + + // Subsequent polls + pollIntervalRef.current = setInterval(poll, POLL_INTERVAL_MS); + + // Timeout + pollTimeoutRef.current = setTimeout(() => { + stopPolling(); + setError("Payment timed out. Please close this window and try again."); + setStatus(CoinbaseOnrampStatus.Failed); + }, PAYMENT_TIMEOUT_MS); + + return () => stopPolling(); + }, [orderId, stopPolling]); + + if (!paymentLink || !orderId) { + return ( +
+

Missing payment information.

+
+ ); + } + + const isCompleted = status === CoinbaseOnrampStatus.Completed; + const isFailed = status === CoinbaseOnrampStatus.Failed; + + return ( +
+ {/* Status bar */} + {(isCompleted || isFailed) && ( +
+ {isCompleted ? ( + <> + + Payment successful! This window will close shortly. + + ) : ( + <> + + {error} + + )} +
+ )} + + {/* Iframe */} +
+ {!iframeLoaded && ( +
+ +
+ )} +