Skip to content

Commit

Permalink
feat: app switch resume flow
Browse files Browse the repository at this point in the history
  • Loading branch information
ravishekhar committed Dec 26, 2024
1 parent f0b5eeb commit ed3a3ad
Show file tree
Hide file tree
Showing 17 changed files with 923 additions and 1 deletion.
1 change: 1 addition & 0 deletions globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module.exports = {
__URI__: {
__CHECKOUT__: "/checkoutnow",
__BUTTONS__: "/smart/buttons",
__PIXEL__: "/smart/pixel",
__MENU__: "/smart/menu",
__QRCODE__: "/smart/qrcode",
__VENMO__: "/smart/checkout/venmo/popup",
Expand Down
6 changes: 6 additions & 0 deletions src/constants/misc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,9 @@ export const ATTRIBUTE = {
};

export const DEFAULT = ("default": "default");

export const APP_SWITCH_RETURN_HASH = {
ONAPPROVE: ("onApprove": "onApprove"),
ONCANCEL: ("onCancel": "onCancel"),
ONERROR: ("onError": "onError"),
};
1 change: 1 addition & 0 deletions src/declarations.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ declare var __PAYPAL_CHECKOUT__: {|
__REMEMBERED_FUNDING__: $ReadOnlyArray<$Values<typeof FUNDING>>,
__URI__: {|
__BUTTONS__: string,
__PIXEL__: string,
__CHECKOUT__: string,
__CARD_FIELDS__: string,
__CARD_FIELD__: string,
Expand Down
6 changes: 6 additions & 0 deletions src/interface/button.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ import {
getModalComponent,
type ModalComponent,
} from "../zoid/modal/component";
import { getPixelComponent, type PixelComponent } from "../zoid/pixel";

export const Buttons: LazyExport<ButtonsComponent> = {
__get__: () => getButtonsComponent(),
};

export const ResumePixel: LazyExport<PixelComponent> = {
__get__: () => getPixelComponent(),
};

export const Checkout: LazyProtectedExport<CheckoutComponent> = {
__get__: () => protectedExport(getCheckoutComponent()),
};
Expand Down Expand Up @@ -93,6 +98,7 @@ export const destroyAll: LazyProtectedExport<typeof destroyComponents> = {
export function setup() {
getButtonsComponent();
getCheckoutComponent();
getPixelComponent();
}

export function destroy(err?: mixed) {
Expand Down
64 changes: 64 additions & 0 deletions src/lib/appSwitchResume.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* @flow */
import { FUNDING } from "@paypal/sdk-constants/src";

import { APP_SWITCH_RETURN_HASH } from "../constants";

export type AppSwitchResumeParams = {|
orderID?: ?string,
buttonSessionID: string,
payerID?: ?string,
billingToken?: ?string,
vaultSetupToken?: ?string,
paymentID?: ?string,
subscriptionID?: ?string,
fundingSource?: ?$Values<typeof FUNDING>,
checkoutState: "onApprove" | "onCancel" | "onError",
|};

export function getAppSwitchResumeParams(): AppSwitchResumeParams | null {
const urlHash = String(window.location.hash).replace("#", "");
const isPostApprovalAction = [
APP_SWITCH_RETURN_HASH.ONAPPROVE,
APP_SWITCH_RETURN_HASH.ONCANCEL,
APP_SWITCH_RETURN_HASH.ONERROR,
].includes(urlHash);
if (!isPostApprovalAction) {
return null;
}
// eslint-disable-next-line compat/compat
const search = new URLSearchParams(window.location.search);
const orderID = search.get("orderID");
const payerID = search.get("payerID");
const buttonSessionID = search.get("buttonSessionID");
const billingToken = search.get("billingToken");
const paymentID = search.get("paymentID");
const subscriptionID = search.get("subscriptionID");
const vaultSetupToken = search.get("vaultSetupToken");
const fundingSource = search.get("fundingSource");
if (buttonSessionID) {
const params: AppSwitchResumeParams = {
orderID,
buttonSessionID,
payerID,
billingToken,
paymentID,
subscriptionID,
// URLSearchParams get returns as string,
// but below code excepts a value from list of string.
// $FlowIgnore[incompatible-type]
fundingSource,
vaultSetupToken,
// the isPostApprovalAction already ensures
// that the function will exit if url hash is not one of supported values.
// $FlowIgnore[incompatible-type]
checkoutState: urlHash,
};
return params;
}
return null;
}

export function isAppSwitchResumeFlow(): boolean {
const params = getAppSwitchResumeParams();
return params !== null;
}
98 changes: 98 additions & 0 deletions src/lib/appSwithResume.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/* @flow */

import { vi, describe, expect } from "vitest";

import {
isAppSwitchResumeFlow,
getAppSwitchResumeParams,
} from "./appSwitchResume";

describe("app switch resume flow", () => {
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
const buttonSessionID = "uid_button_session_123444";
const orderID = "EC-1223114";
const fundingSource = "paypal";

test("should test fetching resume params when its non resume flow", () => {
const params = getAppSwitchResumeParams();

expect.assertions(2);
expect(params).toEqual(null);
expect(isAppSwitchResumeFlow()).toEqual(false);
});

test("should test fetching resume params when parameters are correctly passed", () => {
vi.spyOn(window, "location", "get").mockReturnValue({
hash: "#onApprove",
search: `buttonSessionID=${buttonSessionID}&orderID=${orderID}&fundingSource=${fundingSource}`,
});

const params = getAppSwitchResumeParams();

expect.assertions(2);
expect(params).toEqual({
billingToken: null,
buttonSessionID,
checkoutState: "onApprove",
fundingSource,
orderID,
payerID: null,
paymentID: null,
subscriptionID: null,
vaultSetupToken: null,
});
expect(isAppSwitchResumeFlow()).toEqual(true);
});

test("should test fetching resume params with invalid callback passed", () => {
vi.spyOn(window, "location", "get").mockReturnValue({
hash: "#Unknown",
search: `buttonSessionID=${buttonSessionID}&orderID=${orderID}&fundingSource=${fundingSource}`,
});

const params = getAppSwitchResumeParams();

expect.assertions(2);
expect(params).toEqual(null);
expect(isAppSwitchResumeFlow()).toEqual(false);
});

test("should test null fetching resume params with invalid callback passed", () => {
vi.spyOn(window, "location", "get").mockReturnValue({
hash: "#Unknown",
search: `buttonSessionID=${buttonSessionID}&orderID=${orderID}&fundingSource=${fundingSource}`,
});

const params = getAppSwitchResumeParams();

expect.assertions(2);
expect(params).toEqual(null);
expect(isAppSwitchResumeFlow()).toEqual(false);
});

test("should test fetching resume params when parameters are correctly passed", () => {
vi.spyOn(window, "location", "get").mockReturnValue({
hash: "#onApprove",
search: `buttonSessionID=${buttonSessionID}&orderID=${orderID}&fundingSource=${fundingSource}&billingToken=BA-124&payerID=PP-122&paymentID=PAY-123&subscriptionID=I-1234&vaultSetupToken=VA-3`,
});

const params = getAppSwitchResumeParams();

expect.assertions(2);
expect(params).toEqual({
billingToken: "BA-124",
buttonSessionID,
checkoutState: "onApprove",
fundingSource,
orderID,
payerID: "PP-122",
paymentID: "PAY-123",
subscriptionID: "I-1234",
vaultSetupToken: "VA-3",
});
expect(isAppSwitchResumeFlow()).toEqual(true);
});
});
1 change: 1 addition & 0 deletions src/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export * from "./errors";
export * from "./isRTLLanguage";
export * from "./security";
export * from "./session";
export * from "./appSwitchResume";
export * from "./perceived-latency-instrumentation";
14 changes: 14 additions & 0 deletions src/ui/buttons/props.js
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,21 @@ export type PrerenderDetails = {|

export type GetPrerenderDetails = () => PrerenderDetails | void;

export type ButtonExtensions = {|
hasReturned: () => boolean,
resume: () => void,
|};

export type ButtonProps = {|
// app switch properties
appSwitchWhenAvailable: string,
listenForHashChanges: () => void,
removeListenerForHashChanges: () => void,
// Not passed to child iframe
// change any to HashChangeEvent when we move to typescript
// eslint-disable-next-line flowtype/no-weak-types
hashChangeHandler: (event: any) => void,

fundingSource?: ?$Values<typeof FUNDING>,
intent: $Values<typeof INTENT>,
createOrder: CreateOrder,
Expand Down
105 changes: 104 additions & 1 deletion src/zoid/buttons/component.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
} from "@paypal/funding-components/src";
import { ZalgoPromise } from "@krakenjs/zalgo-promise/src";
import { create, EVENT, type ZoidComponent } from "@krakenjs/zoid/src";
import { send as postRobotSend } from "@krakenjs/post-robot/src";
import {
uniqueID,
memoize,
Expand All @@ -73,13 +74,17 @@ import {
sessionState,
logLatencyInstrumentationPhase,
prepareInstrumentationPayload,
isAppSwitchResumeFlow,
getAppSwitchResumeParams,
} from "../../lib";
import {
normalizeButtonStyle,
normalizeButtonMessage,
type ButtonProps,
type ButtonExtensions,
} from "../../ui/buttons/props";
import { isFundingEligible } from "../../funding";
import { getPixelComponent } from "../pixel";
import { CLASS } from "../../constants";

import { containerTemplate } from "./container";
Expand All @@ -95,15 +100,56 @@ import {
getModal,
} from "./util";

export type ButtonsComponent = ZoidComponent<ButtonProps>;
export type ButtonsComponent = ZoidComponent<
ButtonProps,
void,
void,
ButtonExtensions
>;

export const getButtonsComponent: () => ButtonsComponent = memoize(() => {
const queriedEligibleFunding = [];

return create({
tag: "paypal-buttons",
url: () => `${getPayPalDomain()}${__PAYPAL_CHECKOUT__.__URI__.__BUTTONS__}`,

domain: getPayPalDomainRegex(),
getExtensions: (parent) => {
return {
hasReturned: () => {
return isAppSwitchResumeFlow();
},
resume: () => {
const resumeFlowParams = getAppSwitchResumeParams();
if (!resumeFlowParams) {
throw new Error("Resume Flow is not supported.");
}
getLogger().metricCounter({
namespace: "resume_flow.init.count",
event: "info",
dimensions: {
orderID: Boolean(resumeFlowParams.orderID),
vaultSessionID: Boolean(resumeFlowParams.vaultSetupToken),
billingToken: Boolean(resumeFlowParams.billingToken),
payerID: Boolean(resumeFlowParams.payerID),
},
});
const resumeComponent = getPixelComponent();
const parentProps = parent.getProps();
resumeComponent({
onApprove: parentProps.onApprove,
// $FlowIgnore[incompatible-call]
onError: parentProps.onError,
// $FlowIgnore[prop-missing] onCancel is incorrectly declared as oncancel in button props
onCancel: parentProps.onCancel,
onClick: parentProps.onClick,
onComplete: parentProps.onComplete,
resumeFlowParams,
}).render("body");
},
};
},

autoResize: {
width: false,
Expand Down Expand Up @@ -246,6 +292,63 @@ export const getButtonsComponent: () => ButtonsComponent = memoize(() => {
},

props: {
// App Switch Properties
appSwitchWhenAvailable: {
// this value is a string for now while we test the app switch
// feature. Before we give this to a real merchant, we should
// change this to a boolean - Shane 11 Dec 2024
type: "string",
required: false,
},

hashChangeHandler: {
type: "function",
sendToChild: false,
queryParam: false,
required: false,
value: () => (event) => {
const iframes = document.querySelectorAll("iframe");

// I don't understand why but trying to make iframes which is a NodeList
// into an Iterable (so we could do a for..of loop or .forEach) is not
// working. It ends up iterating over itself so instead of looping over the contents
// of the NodeList you loop over the NodeList itself which is extremely unexpected
// for..in works though :shrug: - Shane 11 Dec 2024
for (let i = 0; i < iframes.length; i++) {
if (iframes[i].name.includes("zoid__paypal_buttons")) {
postRobotSend(
iframes[i].contentWindow,
"paypal-hashchange",
{
url: event.newURL,
},
{ domain: getPayPalDomain() }
);
}
}
},
},

listenForHashChanges: {
type: "function",
queryParam: false,
value:
({ props }) =>
() => {
window.addEventListener("hashchange", props.hashChangeHandler);
},
},

removeListenerForHashChanges: {
type: "function",
queryParam: false,
value:
({ props }) =>
() => {
window.removeEventListener("hashchange", props.hashChangeHandler);
},
},

// allowBillingPayments prop is used by Honey Extension to render the one-click button
// with payment methods & to use the payment methods instead of the Billing Agreement
allowBillingPayments: {
Expand Down
Loading

0 comments on commit ed3a3ad

Please sign in to comment.