Skip to content

feat: message hover and click behavior and modal #2352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Mar 28, 2024
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
1 change: 1 addition & 0 deletions globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module.exports = {
__CARD_FIELD__: "/smart/card-field",
__WALLET__: "/smart/wallet",
__PAYMENT_FIELDS__: "/altpayfields",
__MESSAGE_MODAL__: "https://www.paypalobjects.com/upstream/bizcomponents/js/modal.js",
},
},
};
1 change: 1 addition & 0 deletions src/declarations.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ declare var __PAYPAL_CHECKOUT__: {|
__VENMO__: string,
__WALLET__: string,
__PAYMENT_FIELDS__: string,
__MESSAGE_MODAL__: string,
|},
|};

Expand Down
2 changes: 1 addition & 1 deletion src/ui/buttons/message.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function Message({ markup, position }: MessageProps): ChildType {
if (typeof markup !== "string") {
return (
<div
className={`${messageClassNames} ${CLASS.BUTTON_MESSAGE_RESERVE}`}
class={`${messageClassNames} ${CLASS.BUTTON_MESSAGE_RESERVE}`}
style={`height:${INITIAL_RESERVED_HEIGHT}`}
/>
);
Expand Down
11 changes: 7 additions & 4 deletions src/ui/buttons/props.js
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ export function normalizeButtonMessage(
}
}

if (offer) {
if (typeof offer !== "undefined") {
if (!Array.isArray(offer)) {
throw new TypeError(
`Expected message.offer to be an array of strings, got: ${String(
Expand All @@ -776,15 +776,18 @@ export function normalizeButtonMessage(
}
}

if (color && !values(MESSAGE_COLOR).includes(color)) {
if (typeof color !== "undefined" && !values(MESSAGE_COLOR).includes(color)) {
throw new Error(`Invalid color: ${color}`);
}

if (position && !values(MESSAGE_POSITION).includes(position)) {
if (
typeof position !== "undefined" &&
!values(MESSAGE_POSITION).includes(position)
) {
throw new Error(`Invalid position: ${position}`);
}

if (align && !values(MESSAGE_ALIGN).includes(align)) {
if (typeof align !== "undefined" && !values(MESSAGE_ALIGN).includes(align)) {
throw new Error(`Invalid align: ${align}`);
}

Expand Down
96 changes: 96 additions & 0 deletions src/zoid/buttons/component.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
getRenderedButtons,
getButtonSize,
getButtonExperiments,
getModal,
} from "./util";

export type ButtonsComponent = ZoidComponent<ButtonProps>;
Expand Down Expand Up @@ -681,6 +682,101 @@ export const getButtonsComponent: () => ButtonsComponent = memoize(() => {
},
},

onMessageClick: {
type: "function",
required: false,
value: ({ props }) => {
return async ({
offerType,
messageType,
offerCountryCode,
creditProductIdentifier,
}) => {
const { message, clientID, merchantID, currency, buttonSessionID } =
props;
const amount = message?.amount;

getLogger()
.info("button_message_click")
.track({
[FPTI_KEY.TRANSITION]: "button_message_click",
[FPTI_KEY.STATE]: "BUTTON_MESSAGE",
[FPTI_KEY.CONTEXT_ID]: buttonSessionID,
[FPTI_KEY.CONTEXT_TYPE]: "button_session_id",
[FPTI_KEY.EVENT_NAME]: "message_click",
// adding temp string here for our sdk constants
button_message_offer_type: offerType,
button_message_credit_product_identifier:
creditProductIdentifier,
button_message_type: messageType,
button_message_position: message?.position,
button_message_align: message?.align,
button_message_color: message?.color,
button_message_offer_country: offerCountryCode,
button_message_amount: amount,
[FPTI_KEY.BUTTON_SESSION_UID]: buttonSessionID,
});

const modalInstance = await getModal(clientID, merchantID);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the error scenario inside getModal that results in undefined being returned here. We may want to include a check against that before calling modalInstance.show() which would then throw another error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do we do if getModal is undefined, just not return anything?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. Something as simple as return modalInstance?.show() would work.

return modalInstance?.show({
amount,
offer: offerType,
currency,
});
};
},
},

onMessageHover: {
type: "function",
required: false,
value: ({ props }) => {
return () => {
// offerType, messageType, offerCountryCode, and creditProductIdentifier are passed in and may be used in an upcoming message hover logging feature

// lazy loads the modal, to be memoized and executed onMessageClick
const { clientID, merchantID } = props;
return getModal(clientID, merchantID);
};
},
},

onMessageReady: {
type: "function",
required: false,
value: ({ props }) => {
return ({
offerType,
messageType,
offerCountryCode,
creditProductIdentifier,
}) => {
const { message, buttonSessionID } = props;

getLogger()
.info("button_message_render")
.track({
[FPTI_KEY.TRANSITION]: "button_message_render",
[FPTI_KEY.STATE]: "BUTTON_MESSAGE",
[FPTI_KEY.CONTEXT_ID]: buttonSessionID,
[FPTI_KEY.CONTEXT_TYPE]: "button_session_id",
[FPTI_KEY.EVENT_NAME]: "message_render",
// adding temp string here for our sdk constants
button_message_offer_type: offerType,
button_message_credit_product_identifier:
creditProductIdentifier,
button_message_type: messageType,
button_message_posiiton: message?.position,
button_message_align: message?.align,
button_message_color: message?.color,
button_message_offer_country: offerCountryCode,
button_message_amount: message?.amount,
[FPTI_KEY.BUTTON_SESSION_UID]: buttonSessionID,
});
};
},
},

onShippingAddressChange: {
type: "function",
required: false,
Expand Down
53 changes: 53 additions & 0 deletions src/zoid/buttons/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getElement,
isStandAlone,
once,
memoize,
} from "@krakenjs/belter/src";
import { FUNDING } from "@paypal/sdk-constants/src";
import {
Expand All @@ -22,6 +23,8 @@ import {
getFundingEligibility,
getPlatform,
getComponents,
getEnv,
getNamespace,
} from "@paypal/sdk-client/src";
import { getRefinedFundingEligibility } from "@paypal/funding-components/src";

Expand Down Expand Up @@ -357,3 +360,53 @@ export function getButtonSize(
}
}
}

function buildModalBundleUrl(): string {
let url = __PAYPAL_CHECKOUT__.__URI__.__MESSAGE_MODAL__;
if (getEnv() === "sandbox") {
url = url.replace("/js/", "/sandbox/");
} else if (getEnv() === "stage" || getEnv() === "local") {
url = url.replace("/js/", "/stage/");
}
return url;
}

export const getModal: (
clientID: string,
merchantID: $ReadOnlyArray<string> | void
) => Object = memoize(async (clientID, merchantID) => {
try {
const namespace = getNamespace();
if (!window[namespace].MessagesModal) {
// eslint-disable-next-line no-restricted-globals, promise/no-native
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.setAttribute("data-pp-namespace", namespace);
script.src = buildModalBundleUrl();
script.addEventListener("error", (err: Event) => {
reject(err);
});
script.addEventListener("load", () => {
document.body?.removeChild(script);
resolve();
});
document.body?.appendChild(script);
});
}

return window[namespace].MessagesModal({
account: `client-id:${clientID}`,
merchantId: merchantID?.join(",") || undefined,
});
} catch (err) {
// $FlowFixMe flow doesn't seem to understand that the reset function property exists on the function object itself
getModal.reset();
getLogger()
.error("button_message_modal_fetch_error", { err })
.track({
err: err.message || "BUTTON_MESSAGE_MODAL_FETCH_ERROR",
details: err.details,
stack: JSON.stringify(err.stack || err),
});
}
});
1 change: 1 addition & 0 deletions test/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function getTestGlobals(productionGlobals) {
__CARD_FIELD__: `/base/test/integration/windows/card-field/index.htm`,
__WALLET__: `/base/test/integration/windows/wallet/index.htm`,
__PAYMENT_FIELDS__: `/base/test/integration/windows/paymentfields/index.htm`,
__MESSAGE_MODAL__: `/base/test/integration/windows/button/modal.js`,
},
},

Expand Down
1 change: 1 addition & 0 deletions test/integration/tests/button/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import "./error";
import "./drivers";
import "./frame";
import "./size";
import "./message";
import "./multiple";
import "./layout";
import "./style";
Expand Down
108 changes: 108 additions & 0 deletions test/integration/tests/button/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { wrapPromise, getElement } from "@krakenjs/belter/src";
import { FUNDING } from "@paypal/sdk-constants/src";
import { getNamespace } from "@paypal/sdk-client/src";

import { CLASS } from "../../../../src/constants";
import {
Expand Down Expand Up @@ -378,4 +379,111 @@ describe(`paypal button message`, () => {
});
});
});

describe("modal", () => {
it("should ensure data-pp-namespace passes in the namespace", (done) => {
window.paypal
.Buttons({
message: {},
test: {
onRender({ hoverMessage }) {
hoverMessage()
.then(() => {
assert.equal(getNamespace(), window.namespace);
done();
})
.catch(done);
},
},
})
.render("#testContainer");
});
it("should ensure getModal callback with clientID and merchantID is called on hover", (done) => {
window.paypal
.Buttons({
message: {},
test: {
onRender({ hoverMessage }) {
hoverMessage()
.then(() => {
assert.ok(
Object.keys(window.paypal.MessagesModal.mock.calledWith)
.length === 2
);
assert.ok(
typeof window.paypal.MessagesModal.mock.calledWith
.account === "string"
);
assert.ok(
typeof window.paypal.MessagesModal.mock.calledWith
.merchantId === "undefined"
);
done();
})
.catch(done);
},
},
})
.render("#testContainer");
});
it("should ensure getModal calls create a script with modal data and called with amount, offer, and currency from props", (done) => {
const props = { offerType: "PAY_LATER", messageType: "GPL" };
window.paypal
.Buttons({
message: {
amount: 101,
},
test: {
onRender({ clickMessage, hoverMessage }) {
hoverMessage()
.then(() => {
return clickMessage(props).then(() => {
assert.equal(
window.paypal.MessagesModal.mock.show.calledWith.amount,
101
);
assert.equal(
window.paypal.MessagesModal.mock.show.calledWith.offer,
"PAY_LATER"
);
assert.equal(
window.paypal.MessagesModal.mock.show.calledWith.currency,
"USD"
);
done();
});
})
.catch(done);
},
},
})
.render("#testContainer");
});
it("should ensure getModal calls utilize a single modal instance, not creating multiple modals", (done) => {
const props = { offerType: "PAY_LATER", messageType: "GPL" };
window.paypal
.Buttons({
message: {
amount: 101,
},
test: {
onRender({ clickMessage, hoverMessage }) {
hoverMessage()
.then(() => {
return clickMessage(props).then(() => {
return hoverMessage().then(() => {
return clickMessage(props).then(() => {
assert.equal(window.paypal.MessagesModal.mock.calls, 1);
done();
});
});
});
})
.catch(done);
},
},
})
.render("#testContainer");
});
});
});
6 changes: 6 additions & 0 deletions test/integration/windows/button/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,5 +236,11 @@ if (onRender) {
click() {
getElement(".paypal-button", document).click();
},
hoverMessage(): ZalgoPromise<void> | void {
return window.xprops.onMessageHover();
},
clickMessage({ offerType, messageType }): ZalgoPromise<void> | void {
return window.xprops.onMessageClick({ offerType, messageType });
},
});
}
Loading
Loading