Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion packages/sdk/src/modules/payments-checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export interface MountCheckoutDeps {

async function scriptUrlFor(
provider: "paypal" | "stripe",
deps: MountCheckoutDeps
deps: Pick<MountCheckoutDeps, "getConfig">
): Promise<string> {
const value = (await deps.getConfig())?.[provider]?.script_url;
let url: URL | undefined;
Expand Down Expand Up @@ -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(
Comment thread
naor-gal marked this conversation as resolved.
Resolved
provider: "paypal" | "stripe",
deps: Pick<MountCheckoutDeps, "getConfig" | "loadScript">
): Promise<void> {
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
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/modules/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
loadScript,
mountCheckout,
PaymentsCheckoutConfig,
preloadCheckout,
} from "./payments-checkout.js";
import {
CancelSubscriptionOptions,
Expand Down Expand Up @@ -101,6 +102,9 @@ export function createPaymentsModule(
loadScript,
});
},
preloadCheckout(provider) {
return preloadCheckout(provider, { getConfig, loadScript });
},
};
}

Expand Down
20 changes: 20 additions & 0 deletions packages/sdk/src/modules/payments.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,26 @@ export interface PaymentsModule {
checkout: MountableCheckout,
options?: MountCheckoutOptions
): Promise<CheckoutMount>;

/**
* 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<void>;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/tests/types/payments.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 57 additions & 0 deletions packages/sdk/tests/unit/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading