"use client"; /** * Stripe's embedded Checkout, mounted inside this storefront's own page. * * The alternative is `window.location.href = session.url`, which hands the * customer to checkout.stripe.com and back: one full page load out of the * brand and one back in, and every step between a plan and an installed eSIM * is a step people drop out on. Embedded keeps the form here; Stripe still * owns the iframe, so no card data touches this app. * * Stripe.js is loaded from js.stripe.com at runtime rather than bundled. That * is Stripe's own requirement — the library must be served from their origin * to stay PCI-compliant — and it keeps the template free of the @stripe/* * packages, so a scaffolded storefront installs exactly what it did before. * * `onComplete` is the point of this component. The session is created with * `redirect_on_completion=if_required`, so a card that needs no redirect * finishes inside the iframe and leaves the customer looking at a done screen * that goes nowhere. onComplete is where we take them onward. */ import { useEffect, useRef, useState } from "react"; const STRIPE_JS_SRC = "https://js.stripe.com/v3/"; /** * Empty when the deploy has no publishable key. The caller checks this before * asking for an embedded session, so an unconfigured storefront falls back to * the hosted redirect instead of mounting a form that can never load. */ const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ""; export const canEmbedStripeCheckout = PUBLISHABLE_KEY.length > 0; interface EmbeddedCheckoutInstance { mount: (selector: string | HTMLElement) => void; unmount: () => void; destroy: () => void; } interface StripeInstance { createEmbeddedCheckoutPage?: (options: { fetchClientSecret: () => Promise; onComplete?: () => void; }) => Promise; /** Name this method carried before `createEmbeddedCheckoutPage`. */ initEmbeddedCheckout?: (options: { fetchClientSecret: () => Promise; onComplete?: () => void; }) => Promise; } type StripeConstructor = (key: string) => StripeInstance; declare global { interface Window { Stripe?: StripeConstructor; } } /** * Load Stripe.js once per document, sharing one in-flight load between mounts. * * The promise is cached rather than the script tag. Attaching listeners to a * tag that has already finished is the trap here: a script that failed fires * neither `load` nor `error` again, so a second attempt would wait forever and * the customer would sit on a spinner on the payment step. On failure the tag * and the cached promise are both dropped, so a retry starts genuinely fresh. */ let stripeJsPromise: Promise | null = null; function loadStripeJs(): Promise { if (typeof window === "undefined") { return Promise.reject(new Error("Stripe.js cannot load on the server")); } if (window.Stripe) return Promise.resolve(window.Stripe); if (stripeJsPromise) return stripeJsPromise; stripeJsPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); const fail = (message: string) => { script.remove(); reject(new Error(message)); }; script.addEventListener( "load", () => { if (window.Stripe) resolve(window.Stripe); else fail("Stripe.js loaded without exposing Stripe"); }, { once: true }, ); script.addEventListener("error", () => fail("Stripe.js failed to load"), { once: true }); script.src = STRIPE_JS_SRC; script.async = true; document.head.appendChild(script); }); // Drop the cache on failure so the next mount retries instead of replaying // the rejection for the rest of the session. stripeJsPromise.catch(() => { stripeJsPromise = null; }); return stripeJsPromise; } interface Props { /** `client_secret` of a Checkout Session created with ui_mode=embedded. */ clientSecret: string; /** * Called once the session completes without a redirect. This is what stops * the customer stranded on Stripe's "payment received" panel. */ onComplete: () => void; /** Stripe.js could not load or mount. The caller falls back to hosted. */ onUnavailable?: (reason: string) => void; } export function EmbeddedStripeCheckout({ clientSecret, onComplete, onUnavailable }: Props) { const containerRef = useRef(null); const [mounted, setMounted] = useState(false); // onComplete and onUnavailable are read through refs so that a caller who // passes inline closures does not re-run this effect and open a second // Checkout instance against one session. const onCompleteRef = useRef(onComplete); onCompleteRef.current = onComplete; const onUnavailableRef = useRef(onUnavailable); onUnavailableRef.current = onUnavailable; useEffect(() => { if (!canEmbedStripeCheckout || !clientSecret) return; let cancelled = false; let instance: EmbeddedCheckoutInstance | null = null; void (async () => { try { const Stripe = await loadStripeJs(); if (cancelled) return; const stripe = Stripe(PUBLISHABLE_KEY); const create = stripe.createEmbeddedCheckoutPage ?? stripe.initEmbeddedCheckout; if (!create) throw new Error("Stripe.js has no embedded Checkout constructor"); instance = await create.call(stripe, { // A pure getter. Stripe re-reads this when it remounts, and fetching // a fresh session here would charge one customer against two orders. fetchClientSecret: () => Promise.resolve(clientSecret), onComplete: () => onCompleteRef.current(), }); if (cancelled || !containerRef.current) { instance.destroy(); return; } instance.mount(containerRef.current); setMounted(true); } catch (error) { if (cancelled) return; onUnavailableRef.current?.(error instanceof Error ? error.message : String(error)); } })(); return () => { cancelled = true; // destroy() rather than unmount(): React may remount this component, and // a stale instance holding the same session would render twice. try { instance?.destroy(); } catch { // The iframe is already gone. Nothing to clean up. } }; }, [clientSecret]); if (!canEmbedStripeCheckout) return null; return (
{!mounted && (

Loading the payment form…

)}
); }