diff --git a/packages/sdk/src/modules/payments-checkout.ts b/packages/sdk/src/modules/payments-checkout.ts index a9b1df43..236a3e4e 100644 --- a/packages/sdk/src/modules/payments-checkout.ts +++ b/packages/sdk/src/modules/payments-checkout.ts @@ -93,7 +93,7 @@ export interface MountCheckoutDeps { async function scriptUrlFor( provider: "paypal" | "stripe", - deps: MountCheckoutDeps + deps: Pick ): Promise { const value = (await deps.getConfig())?.[provider]?.script_url; let url: URL | undefined; @@ -337,6 +337,25 @@ async function mountPayPal( }; } +/** + * Fetches the payments config and loads the provider's script ahead of mountCheckout(). + * @internal + */ +export async function preloadCheckout( + provider: "paypal" | "stripe", + deps: Pick +): Promise { + if (typeof window === "undefined" || typeof document === "undefined") return; + const read = () => (provider === "paypal" ? browserGlobals().paypal : browserGlobals().Stripe); + try { + if (!read()) { + await deps.loadScript(await scriptUrlFor(provider, deps)); + } + } catch { + // A hint only: mountCheckout() fetches and loads again and reports the failure. + } +} + /** * Mounts the embedded checkout that createCheckout() returned. * @internal diff --git a/packages/sdk/src/modules/payments.ts b/packages/sdk/src/modules/payments.ts index 4355218e..666961bd 100644 --- a/packages/sdk/src/modules/payments.ts +++ b/packages/sdk/src/modules/payments.ts @@ -3,6 +3,7 @@ import { loadScript, mountCheckout, PaymentsCheckoutConfig, + preloadCheckout, } from "./payments-checkout.js"; import { CancelSubscriptionOptions, @@ -101,6 +102,9 @@ export function createPaymentsModule( loadScript, }); }, + preloadCheckout(provider) { + return preloadCheckout(provider, { getConfig, loadScript }); + }, }; } diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts index e027f21f..dc1a2cf6 100644 --- a/packages/sdk/src/modules/payments.types.ts +++ b/packages/sdk/src/modules/payments.types.ts @@ -568,6 +568,26 @@ export interface PaymentsModule { checkout: MountableCheckout, options?: MountCheckoutOptions ): Promise; + + /** + * Loads the provider's checkout script before you mount a checkout. + * + * Call it when the checkout page or form opens, while the buyer is still filling it in. A later {@linkcode mountCheckout | mountCheckout()} then skips loading the script and renders sooner. + * + * It never rejects, so you don't need to await it or catch errors. If the script can't load, `mountCheckout()` tries again and rejects with the error. In the backend it does nothing. + * + * @param provider - The provider the app is connected to. + * @returns Promise resolving once the script has loaded or failed to load. + * + * @example + * ```tsx + * // Start loading PayPal when the checkout form opens + * useEffect(() => { + * base44.payments.preloadCheckout('paypal'); + * }, []); + * ``` + */ + preloadCheckout(provider: "paypal" | "stripe"): Promise; } /** diff --git a/packages/sdk/tests/types/payments.types.ts b/packages/sdk/tests/types/payments.types.ts index de00516b..bc09edfd 100644 --- a/packages/sdk/tests/types/payments.types.ts +++ b/packages/sdk/tests/types/payments.types.ts @@ -27,6 +27,11 @@ base44.payments.refund; // @ts-expect-error The service role runs in backend code, where there is nothing to mount. base44.asServiceRole.payments.mountCheckout; +base44.payments.preloadCheckout("paypal"); + +// @ts-expect-error Only connected checkout providers have a script to preload. +base44.payments.preloadCheckout("wix"); + base44.asServiceRole.payments.listPurchases({ buyer_email: "buyer@example.com" }); // @ts-expect-error Only the service role can filter by another buyer's email. diff --git a/packages/sdk/tests/unit/payments.test.ts b/packages/sdk/tests/unit/payments.test.ts index 97e5d2b2..a2eec6fc 100644 --- a/packages/sdk/tests/unit/payments.test.ts +++ b/packages/sdk/tests/unit/payments.test.ts @@ -773,3 +773,60 @@ describe("payments config", () => { expect(scope.isDone()).toBe(true); }); }); + +describe("preloadCheckout", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const configPath = `/api/apps/${appId}/payments/config`; + const scriptUrl = "https://www.sandbox.paypal.com/web-sdk/v6/core"; + + afterEach(() => { + nock.cleanAll(); + vi.unstubAllGlobals(); + }); + + async function setup() { + vi.resetModules(); + const { createClient: create } = await import("../../src/index.ts"); + const scripts: any[] = []; + vi.stubGlobal("window", {}); + vi.stubGlobal("document", { + createElement: () => ({ remove: vi.fn() }), + head: { appendChild: (script: any) => scripts.push(script) }, + }); + const base44 = create({ serverUrl, appId, token: "user-token", analytics: { enabled: false } }); + return { base44, scripts }; + } + + test("fetches the config and loads the script once across calls", async () => { + const { base44, scripts } = await setup(); + const scope = nock(serverUrl).get(configPath).once().reply(200, { paypal: { script_url: scriptUrl } }); + + const preload = base44.payments.preloadCheckout("paypal"); + await vi.waitFor(() => expect(scripts).toHaveLength(1)); + scripts[0].onload(); + await expect(preload).resolves.toBeUndefined(); + base44.payments.preloadCheckout("paypal"); + + expect(scripts[0].src).toBe(scriptUrl); + expect(scripts).toHaveLength(1); + expect(scope.isDone()).toBe(true); + }); + + test("resolves without loading anything when the config has no valid script URL", async () => { + const { base44, scripts } = await setup(); + nock(serverUrl).get(configPath).reply(200, { paypal: { script_url: "https://evil.example.com/sdk.js" } }); + + await expect(base44.payments.preloadCheckout("paypal")).resolves.toBeUndefined(); + expect(scripts).toHaveLength(0); + }); + + test("resolves when the config fetch fails", async () => { + const { base44 } = await setup(); + nock(serverUrl) + .get(configPath) + .reply(503, { error: { code: "provider_unavailable", message: "Down", details: {} } }); + + await expect(base44.payments.preloadCheckout("paypal")).resolves.toBeUndefined(); + }); +});