diff --git a/.bundlemonrc.json b/.bundlemonrc.json
index 7d32c78333..df38375e24 100644
--- a/.bundlemonrc.json
+++ b/.bundlemonrc.json
@@ -5,7 +5,7 @@
"path": "size.min.js",
"compression": "gzip",
"maxPercentIncrease": 3.6,
- "maxSize": "77kb"
+ "maxSize": "79kb"
}
],
"reportOutput": [
diff --git a/demo/dev/button-with-message.htm b/demo/dev/button-with-message.htm
new file mode 100644
index 0000000000..c7d9bf2cca
--- /dev/null
+++ b/demo/dev/button-with-message.htm
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/globals.js b/globals.js
index 30b3f726d2..068362ec38 100644
--- a/globals.js
+++ b/globals.js
@@ -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",
},
},
};
diff --git a/package.json b/package.json
index 6a94239981..d14c500ad3 100644
--- a/package.json
+++ b/package.json
@@ -114,7 +114,7 @@
"@paypal/connect-loader-component": "1.1.1",
"@paypal/funding-components": "^1.0.31",
"@paypal/sdk-client": "^4.0.184",
- "@paypal/sdk-constants": "^1.0.141",
+ "@paypal/sdk-constants": "^1.0.146",
"@paypal/sdk-logos": "^2.2.6"
},
"lint-staged": {
diff --git a/src/constants/button.js b/src/constants/button.js
index 835c3717c0..9d34cd87bb 100644
--- a/src/constants/button.js
+++ b/src/constants/button.js
@@ -63,3 +63,24 @@ export const MENU_PLACEMENT = {
ABOVE: ("above": "above"),
BELOW: ("below": "below"),
};
+
+export const MESSAGE_OFFER = {
+ PAY_LATER_LONG_TERM: ("pay_later_long_term": "pay_later_long_term"),
+ PAY_LATER_SHORT_TERM: ("pay_later_short_term": "pay_later_short_term"),
+};
+
+export const MESSAGE_COLOR = {
+ BLACK: ("black": "black"),
+ WHITE: ("white": "white"),
+};
+
+export const MESSAGE_POSITION = {
+ TOP: ("top": "top"),
+ BOTTOM: ("bottom": "bottom"),
+};
+
+export const MESSAGE_ALIGN = {
+ CENTER: ("center": "center"),
+ LEFT: ("left": "left"),
+ RIGHT: ("right": "right"),
+};
diff --git a/src/constants/class.js b/src/constants/class.js
index 7c7d6b97e4..7192580eb7 100644
--- a/src/constants/class.js
+++ b/src/constants/class.js
@@ -42,4 +42,9 @@ export const CLASS = {
HIDDEN: ("hidden": "hidden"),
IMMEDIATE: ("immediate": "immediate"),
+
+ BUTTON_MESSAGE: ("paypal-button-message": "paypal-button-message"),
+
+ BUTTON_MESSAGE_RESERVE:
+ ("paypal-button-message-reserved": "paypal-button-message-reserved"),
};
diff --git a/src/declarations.js b/src/declarations.js
index f408237b21..6985ad993f 100644
--- a/src/declarations.js
+++ b/src/declarations.js
@@ -18,6 +18,7 @@ declare var __PAYPAL_CHECKOUT__: {|
__VENMO__: string,
__WALLET__: string,
__PAYMENT_FIELDS__: string,
+ __MESSAGE_MODAL__: string,
|},
|};
diff --git a/src/ui/buttons/buttons.jsx b/src/ui/buttons/buttons.jsx
index ef2bdc5166..a13642e992 100644
--- a/src/ui/buttons/buttons.jsx
+++ b/src/ui/buttons/buttons.jsx
@@ -15,6 +15,7 @@ import {
BUTTON_NUMBER,
BUTTON_LAYOUT,
BUTTON_FLOW,
+ MESSAGE_POSITION,
} from "../../constants";
import {
determineEligibleFunding,
@@ -34,6 +35,8 @@ import { Button } from "./button";
import { TagLine } from "./tagline";
import { Script } from "./script";
import { PoweredByPayPal } from "./poweredBy";
+import { Message } from "./message";
+import { calculateShowPoweredBy } from "./util";
type GetWalletInstrumentOptions = {|
wallet: ?Wallet,
@@ -177,6 +180,8 @@ export function Buttons(props: ButtonsProps): ElementNode {
supportedNativeBrowser,
showPayLabel,
displayOnly,
+ message,
+ messageMarkup,
} = normalizeButtonProps(props);
const { layout, shape, tagline } = style;
@@ -237,6 +242,14 @@ export function Buttons(props: ButtonsProps): ElementNode {
return i;
};
+ const showTagline =
+ tagline &&
+ layout === BUTTON_LAYOUT.HORIZONTAL &&
+ !fundingSource &&
+ !message;
+
+ const showPoweredBy = calculateShowPoweredBy(layout, fundingSources);
+
return (
+ {message && message.position === MESSAGE_POSITION.TOP ? (
+
+ ) : null}
+
{fundingSources.map((source, i) => (
))}
- {tagline && layout === BUTTON_LAYOUT.HORIZONTAL && !fundingSource ? (
+ {showTagline ? (
) : null}
- {layout === BUTTON_LAYOUT.VERTICAL &&
- fundingSources.indexOf(FUNDING.CARD) !== -1 ? (
-
+ {showPoweredBy ? : null}
+
+ {message && message.position === MESSAGE_POSITION.BOTTOM ? (
+
) : null}
diff --git a/src/ui/buttons/message.jsx b/src/ui/buttons/message.jsx
new file mode 100644
index 0000000000..b37a73ed68
--- /dev/null
+++ b/src/ui/buttons/message.jsx
@@ -0,0 +1,31 @@
+/* @flow */
+/** @jsx node */
+
+import { node, type ChildType } from "@krakenjs/jsx-pragmatic/src";
+
+import { CLASS } from "../../constants";
+
+const INITIAL_RESERVED_HEIGHT = "36px";
+
+type MessageProps = {|
+ markup: ?string,
+ position: string,
+|};
+
+export function Message({ markup, position }: MessageProps): ChildType {
+ const messageClassNames = [
+ CLASS.BUTTON_MESSAGE,
+ `${CLASS.BUTTON_MESSAGE}-${position}`,
+ ].join(" ");
+
+ if (typeof markup !== "string") {
+ return (
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/ui/buttons/props.js b/src/ui/buttons/props.js
index 6dc19bf6d0..343a32d4a2 100644
--- a/src/ui/buttons/props.js
+++ b/src/ui/buttons/props.js
@@ -40,11 +40,15 @@ import {
BUTTON_SIZE,
BUTTON_FLOW,
MENU_PLACEMENT,
+ MESSAGE_OFFER,
+ MESSAGE_COLOR,
+ MESSAGE_POSITION,
+ MESSAGE_ALIGN,
} from "../../constants";
import { getFundingConfig, isFundingEligible } from "../../funding";
import { BUTTON_SIZE_STYLE } from "./config";
-import { isBorderRadiusNumber } from "./util";
+import { isBorderRadiusNumber, calculateMessagePosition } from "./util";
export type CreateOrderData = {||} | {||};
@@ -424,6 +428,22 @@ export type ApplePaySessionConfigRequest = (
request: Object
) => ApplePaySessionConfig;
+export type ButtonMessage = {|
+ amount?: number,
+ offer?: $ReadOnlyArray<$Values>,
+ color: $Values,
+ position: $Values,
+ align: $Values,
+|};
+
+export type ButtonMessageInputs = {|
+ amount?: number | void,
+ offer?: $ReadOnlyArray<$Values> | void,
+ color?: $Values | void,
+ position?: $Values | void,
+ align?: $Values | void,
+|};
+
export type RenderButtonProps = {|
style: ButtonStyle,
locale: LocaleType,
@@ -458,6 +478,8 @@ export type RenderButtonProps = {|
supportedNativeBrowser: boolean,
showPayLabel: boolean,
displayOnly?: $ReadOnlyArray<$Values>,
+ message?: ButtonMessage,
+ messageMarkup?: string,
|};
export type PrerenderDetails = {|
@@ -517,6 +539,8 @@ export type ButtonProps = {|
createVaultSetupToken: CreateVaultSetupToken,
displayOnly?: $ReadOnlyArray<$Values>,
hostedButtonId?: string,
+ message?: ButtonMessage,
+ messageMarkup?: string,
|};
// eslint-disable-next-line flowtype/require-exact-type
@@ -559,6 +583,9 @@ export type ButtonPropsInputs = {
supportedNativeBrowser: boolean,
showPayLabel: boolean,
displayOnly: $ReadOnlyArray<$Values>,
+ message?: ButtonMessageInputs | void,
+ messageMarkup?: string | void,
+ renderedButtons: $ReadOnlyArray<$Values>,
};
export const DEFAULT_STYLE = {
@@ -707,6 +734,72 @@ export function normalizeButtonStyle(
};
}
+export function normalizeButtonMessage(
+ message: ButtonMessageInputs,
+ layout: $Values,
+ fundingSources: $ReadOnlyArray<$Values>
+): ButtonMessage {
+ const {
+ amount,
+ offer,
+ color = MESSAGE_COLOR.BLACK,
+ position,
+ align = MESSAGE_ALIGN.CENTER,
+ } = message;
+
+ if (typeof amount !== "undefined") {
+ if (typeof amount !== "number") {
+ throw new TypeError(
+ `Expected message.amount to be a number, got: ${amount}`
+ );
+ }
+ if (amount < 0) {
+ throw new Error(
+ `Expected message.amount to be a positive number, got: ${amount}`
+ );
+ }
+ }
+
+ if (typeof offer !== "undefined") {
+ if (!Array.isArray(offer)) {
+ throw new TypeError(
+ `Expected message.offer to be an array of strings, got: ${String(
+ offer
+ )}`
+ );
+ }
+ const invalidOffers = offer.filter(
+ (o) => !values(MESSAGE_OFFER).includes(o)
+ );
+ if (invalidOffers.length > 0) {
+ throw new Error(`Invalid offer(s): ${invalidOffers.join(",")}`);
+ }
+ }
+
+ if (typeof color !== "undefined" && !values(MESSAGE_COLOR).includes(color)) {
+ throw new Error(`Invalid color: ${color}`);
+ }
+
+ if (
+ typeof position !== "undefined" &&
+ !values(MESSAGE_POSITION).includes(position)
+ ) {
+ throw new Error(`Invalid position: ${position}`);
+ }
+
+ if (typeof align !== "undefined" && !values(MESSAGE_ALIGN).includes(align)) {
+ throw new Error(`Invalid align: ${align}`);
+ }
+
+ return {
+ amount,
+ offer,
+ color,
+ position: calculateMessagePosition(fundingSources, layout, position),
+ align,
+ };
+}
+
const COUNTRIES = values(COUNTRY);
const FUNDING_SOURCES = values(FUNDING);
const ENVS = values(ENV);
@@ -761,6 +854,9 @@ export function normalizeButtonProps(
supportedNativeBrowser = false,
showPayLabel = true,
displayOnly = [],
+ message,
+ messageMarkup,
+ renderedButtons,
} = props;
const { country, lang } = locale;
@@ -819,6 +915,11 @@ export function normalizeButtonProps(
}
style = normalizeButtonStyle(props, style);
+ const { layout } = style;
+
+ message = message
+ ? normalizeButtonMessage(message, layout, renderedButtons)
+ : undefined;
return {
clientID,
@@ -852,5 +953,7 @@ export function normalizeButtonProps(
supportedNativeBrowser,
showPayLabel,
displayOnly,
+ message,
+ messageMarkup,
};
}
diff --git a/src/ui/buttons/util.js b/src/ui/buttons/util.js
index 27f18e765f..fc26a7f736 100644
--- a/src/ui/buttons/util.js
+++ b/src/ui/buttons/util.js
@@ -1,5 +1,41 @@
/* @flow */
+import { FUNDING } from "@paypal/sdk-constants/src";
+
+import { BUTTON_LAYOUT, MESSAGE_POSITION } from "../../constants";
+import { ValidationError } from "../../lib";
export function isBorderRadiusNumber(borderRadius?: number): boolean {
return typeof borderRadius === "number";
}
+
+export function calculateShowPoweredBy(
+ layout: $Values,
+ fundingSources: $ReadOnlyArray<$Values>
+): boolean {
+ return (
+ layout === BUTTON_LAYOUT.VERTICAL && fundingSources.includes(FUNDING.CARD)
+ );
+}
+
+export function calculateMessagePosition(
+ fundingSources: $ReadOnlyArray<$Values>,
+ layout: $Values,
+ position?: $Values
+): $Values {
+ const showPoweredBy = calculateShowPoweredBy(layout, fundingSources);
+
+ if (showPoweredBy && position === MESSAGE_POSITION.BOTTOM) {
+ throw new ValidationError(
+ "Message position must be 'top' when Debit and/or Credit Card button is present"
+ );
+ }
+
+ if (
+ showPoweredBy ||
+ position === MESSAGE_POSITION.TOP ||
+ (layout === BUTTON_LAYOUT.VERTICAL && !position)
+ ) {
+ return MESSAGE_POSITION.TOP;
+ }
+ return MESSAGE_POSITION.BOTTOM;
+}
diff --git a/src/zoid/buttons/component.jsx b/src/zoid/buttons/component.jsx
index 06a0b6ec02..34e860d916 100644
--- a/src/zoid/buttons/component.jsx
+++ b/src/zoid/buttons/component.jsx
@@ -73,7 +73,11 @@ import {
logLatencyInstrumentationPhase,
prepareInstrumentationPayload,
} from "../../lib";
-import { normalizeButtonStyle, type ButtonProps } from "../../ui/buttons/props";
+import {
+ normalizeButtonStyle,
+ normalizeButtonMessage,
+ type ButtonProps,
+} from "../../ui/buttons/props";
import { isFundingEligible } from "../../funding";
import { containerTemplate } from "./container";
@@ -86,6 +90,7 @@ import {
getRenderedButtons,
getButtonSize,
getButtonExperiments,
+ getModal,
} from "./util";
export type ButtonsComponent = ZoidComponent;
@@ -635,6 +640,24 @@ export const getButtonsComponent: () => ButtonsComponent = memoize(() => {
value: getMerchantRequestedPopupsDisabled,
},
+ message: {
+ type: "object",
+ queryParam: true,
+ required: false,
+ decorate: ({ props, value }) => {
+ const {
+ style: { layout },
+ renderedButtons: fundingSources,
+ } = props;
+ return normalizeButtonMessage(
+ // $FlowFixMe
+ value,
+ layout,
+ fundingSources
+ );
+ },
+ },
+
nonce: {
type: "string",
default: getCSPNonce,
@@ -684,6 +707,103 @@ 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.BUTTON_SESSION_UID]: buttonSessionID,
+ [FPTI_KEY.CONTEXT_ID]: buttonSessionID,
+ [FPTI_KEY.CONTEXT_TYPE]: "button_session_id",
+ [FPTI_KEY.EVENT_NAME]: "message_click",
+ [FPTI_KEY.SELLER_ID]: merchantID?.join(","),
+ [FPTI_KEY.BUTTON_MESSAGE_OFFER_TYPE]: offerType,
+ [FPTI_KEY.BUTTON_MESSAGE_CREDIT_PRODUCT_IDENTIFIER]:
+ creditProductIdentifier,
+ [FPTI_KEY.BUTTON_MESSAGE_TYPE]: messageType,
+ [FPTI_KEY.BUTTON_MESSAGE_POSITION]: message?.position,
+ [FPTI_KEY.BUTTON_MESSAGE_ALIGN]: message?.align,
+ [FPTI_KEY.BUTTON_MESSAGE_COLOR]: message?.color,
+ [FPTI_KEY.BUTTON_MESSAGE_OFFER_COUNTRY]: offerCountryCode,
+ [FPTI_KEY.BUTTON_MESSAGE_CURRENCY]: currency,
+ [FPTI_KEY.BUTTON_MESSAGE_AMOUNT]: amount,
+ });
+
+ const modalInstance = await getModal(clientID, merchantID);
+ 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, currency, merchantID } = props;
+
+ getLogger()
+ .info("button_message_render")
+ .track({
+ [FPTI_KEY.TRANSITION]: "button_message_render",
+ [FPTI_KEY.STATE]: "BUTTON_MESSAGE",
+ [FPTI_KEY.BUTTON_SESSION_UID]: buttonSessionID,
+ [FPTI_KEY.CONTEXT_ID]: buttonSessionID,
+ [FPTI_KEY.CONTEXT_TYPE]: "button_session_id",
+ [FPTI_KEY.EVENT_NAME]: "message_render",
+ [FPTI_KEY.SELLER_ID]: merchantID?.join(","),
+ [FPTI_KEY.BUTTON_MESSAGE_OFFER_TYPE]: offerType,
+ [FPTI_KEY.BUTTON_MESSAGE_CREDIT_PRODUCT_IDENTIFIER]:
+ creditProductIdentifier,
+ [FPTI_KEY.BUTTON_MESSAGE_TYPE]: messageType,
+ [FPTI_KEY.BUTTON_MESSAGE_POSITION]: message?.position,
+ [FPTI_KEY.BUTTON_MESSAGE_ALIGN]: message?.align,
+ [FPTI_KEY.BUTTON_MESSAGE_COLOR]: message?.color,
+ [FPTI_KEY.BUTTON_MESSAGE_CURRENCY]: currency,
+ [FPTI_KEY.BUTTON_MESSAGE_OFFER_COUNTRY]: offerCountryCode,
+ [FPTI_KEY.BUTTON_MESSAGE_AMOUNT]: message?.amount,
+ });
+ };
+ },
+ },
+
onShippingAddressChange: {
type: "function",
required: false,
diff --git a/src/zoid/buttons/util.js b/src/zoid/buttons/util.js
index f97ebc159e..52346bd62c 100644
--- a/src/zoid/buttons/util.js
+++ b/src/zoid/buttons/util.js
@@ -13,6 +13,7 @@ import {
getElement,
isStandAlone,
once,
+ memoize,
} from "@krakenjs/belter/src";
import { FUNDING } from "@paypal/sdk-constants/src";
import {
@@ -22,6 +23,8 @@ import {
getFundingEligibility,
getPlatform,
getComponents,
+ getEnv,
+ getNamespace,
} from "@paypal/sdk-client/src";
import { getRefinedFundingEligibility } from "@paypal/funding-components/src";
@@ -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 | 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),
+ });
+ }
+});
diff --git a/test/globals.js b/test/globals.js
index 4572223df6..36675e3937 100644
--- a/test/globals.js
+++ b/test/globals.js
@@ -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`,
},
},
diff --git a/test/integration/tests/button/index.js b/test/integration/tests/button/index.js
index 294efefc50..3fa83eed60 100644
--- a/test/integration/tests/button/index.js
+++ b/test/integration/tests/button/index.js
@@ -6,6 +6,7 @@ import "./error";
import "./drivers";
import "./frame";
import "./size";
+import "./message";
import "./multiple";
import "./layout";
import "./style";
diff --git a/test/integration/tests/button/message.js b/test/integration/tests/button/message.js
new file mode 100644
index 0000000000..3365d6e78f
--- /dev/null
+++ b/test/integration/tests/button/message.js
@@ -0,0 +1,560 @@
+/* @flow */
+/* eslint max-lines: 0 */
+
+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 {
+ assert,
+ getElementRecursive,
+ createTestContainer,
+ destroyTestContainer,
+} from "../common";
+
+describe(`paypal button message`, () => {
+ beforeEach(() => {
+ createTestContainer();
+ });
+
+ afterEach(() => {
+ destroyTestContainer();
+ });
+
+ describe("sets computed default values for undefined message properties", () => {
+ it("should populate message color when it is undefined", () => {
+ return wrapPromise(({ expect }) => {
+ window.paypal
+ .Buttons({
+ message: {},
+ test: {
+ onRender: expect("onRender", ({ xprops }) => {
+ const {
+ message: { color },
+ } = xprops;
+ if (!color) {
+ throw new Error(
+ `Expected message color property to be populated: ${JSON.stringify(
+ xprops.message
+ )}`
+ );
+ }
+ }),
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ it("should populate message align(ment) when it is undefined", () => {
+ return wrapPromise(({ expect }) => {
+ window.paypal
+ .Buttons({
+ message: {},
+ test: {
+ onRender: expect("onRender", ({ xprops }) => {
+ const {
+ message: { align },
+ } = xprops;
+ if (!align) {
+ throw new Error(
+ `Expected message align property to be populated: ${JSON.stringify(
+ xprops.message
+ )}`
+ );
+ }
+ }),
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ it("should populate position with bottom when layout is horizontal", () => {
+ return wrapPromise(({ expect }) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "horizontal",
+ },
+ message: {},
+ test: {
+ onRender: expect("onRender", ({ xprops }) => {
+ const {
+ message: { position },
+ } = xprops;
+ assert.equal(position, "bottom");
+ }),
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ it("should populate position with top when layout is vertical", () => {
+ return wrapPromise(({ expect }) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "vertical",
+ },
+ message: {},
+ test: {
+ onRender: expect("onRender", ({ xprops }) => {
+ const {
+ message: { position },
+ } = xprops;
+ assert.equal(position, "top");
+ }),
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ });
+
+ describe("reserves space for message", () => {
+ it("should reserve space for a message when messageMarkup is undefined", (done) => {
+ window.paypal
+ .Buttons({
+ message: {},
+ test: {
+ onRender() {
+ const frame = document.querySelector("#testContainer iframe");
+ if (!frame) {
+ throw new Error(`Cannot find frame`);
+ }
+ // $FlowFixMe
+ const win = frame.contentWindow;
+ const message = win.document.querySelector(
+ ".paypal-button-message"
+ );
+ if (!message) {
+ done(new Error("No message generated"));
+ }
+ const reservationDiv = win.document.querySelector(
+ ".paypal-button-message-reserved"
+ );
+ if (!reservationDiv) {
+ done(new Error("message space was not reserved"));
+ }
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+
+ it("should not reserve space for a message when messageMarkup is a string with length === 0", (done) => {
+ window.paypal
+ .Buttons({
+ message: {},
+ messageMarkup: "",
+ test: {
+ onRender() {
+ const frame = document.querySelector("#testContainer iframe");
+ if (!frame) {
+ throw new Error(`Cannot find frame`);
+ }
+
+ // $FlowFixMe
+ const win = frame.contentWindow;
+ const message = win.document.querySelector(
+ ".paypal-button-message"
+ );
+ if (!message) {
+ done(new Error("No message generated"));
+ }
+ const reservationDiv = win.document.querySelector(
+ ".paypal-button-message-reserved"
+ );
+ if (reservationDiv) {
+ done(new Error("message space was reserved incorrectly"));
+ }
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+
+ it("should not reserve space for a message when messageMarkup is a string with length > 0", (done) => {
+ window.paypal
+ .Buttons({
+ message: {},
+ messageMarkup: "foo",
+ test: {
+ onRender() {
+ const frame = document.querySelector("#testContainer iframe");
+ if (!frame) {
+ throw new Error(`Cannot find frame`);
+ }
+
+ // $FlowFixMe
+ const win = frame.contentWindow;
+ const message = win.document.querySelector(
+ ".paypal-button-message"
+ );
+ if (!message) {
+ done(new Error("No message generated"));
+ }
+ const reservationDiv = win.document.querySelector(
+ ".paypal-button-message-reserved"
+ );
+ if (reservationDiv) {
+ done(new Error("message space was reserved incorrectly"));
+ }
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+
+ describe(`prop considerations`, () => {
+ it("message should take precedence over tagline when both are truthy", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "horizontal",
+ tagline: true,
+ },
+ message: {},
+ test: {
+ onRender() {
+ const frame = getElement("#testContainer iframe");
+ // $FlowFixMe
+ const win = frame.contentWindow;
+ const tagline = win.document.body.querySelector(CLASS.TAGLINE);
+
+ if (tagline) {
+ done(new Error(`Expected tagline not to render`));
+ }
+
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+
+ describe(`placement`, () => {
+ describe("horizontal layout", () => {
+ it("should place message on bottom by default", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "horizontal",
+ },
+ message: {},
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on top when position is top", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "horizontal",
+ },
+ message: {
+ position: "top",
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-top"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on bottom when position is bottom", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "horizontal",
+ },
+ message: {
+ position: "bottom",
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on bottom when no position is specified and credit/debit is a funding source", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "horizontal",
+ },
+ message: {},
+ fundingEligibility: {
+ credit: {
+ eligible: false,
+ },
+ paypal: {
+ eligible: true,
+ },
+ card: {
+ eligible: true,
+ },
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ describe("vertical layout", () => {
+ it("should place message on top by default", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "vertical",
+ },
+ message: {},
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-top"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on top when position is top", (done) => {
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "vertical",
+ },
+ message: {
+ position: "top",
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-top"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it.skip("should place message on bottom when position is bottom and credit/debit is NOT a funding source", (done) => {
+ // skipped because fundingEligibility doesn't seem to be respected as passed in in this test, but confirmed to work as intended on demo page
+ window.paypal
+ .Buttons({
+ style: {
+ layout: "vertical",
+ },
+ message: {
+ position: "bottom",
+ },
+ fundingEligibility: {
+ credit: {
+ eligible: true,
+ },
+ paypal: {
+ eligible: true,
+ },
+ card: {
+ eligible: false,
+ },
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ describe("standalone layout", () => {
+ it("should place message on bottom by default", (done) => {
+ window.paypal
+ .Buttons({
+ fundingSource: FUNDING.PAYPAL,
+ message: {},
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on top when position is top", (done) => {
+ window.paypal
+ .Buttons({
+ fundingSource: FUNDING.PAYPAL,
+ message: {
+ position: "top",
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-top"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on bottom when position is bottom", (done) => {
+ window.paypal
+ .Buttons({
+ fundingSource: FUNDING.PAYPAL,
+ message: {
+ position: "bottom",
+ },
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ it("should place message on bottom when no position is specified and credit/debit is a funding source", (done) => {
+ window.paypal
+ .Buttons({
+ fundingSource: FUNDING.CARD,
+ message: {},
+ test: {
+ onRender() {
+ assert.ok(getElementRecursive(".paypal-button-message-bottom"));
+ done();
+ },
+ },
+ })
+ .render("#testContainer");
+ });
+ });
+ });
+
+ 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");
+ });
+ });
+});
diff --git a/test/integration/tests/button/validation.js b/test/integration/tests/button/validation.js
index 5421cd0025..836b5f919f 100644
--- a/test/integration/tests/button/validation.js
+++ b/test/integration/tests/button/validation.js
@@ -868,6 +868,7 @@ const buttonConfigs = [
},
})),
},
+
{
name: "borderRadius",
@@ -910,6 +911,126 @@ const buttonConfigs = [
},
})),
},
+
+ {
+ name: "message",
+
+ cases: [
+ {
+ message: {
+ amount: 100,
+ offer: ["pay_later_long_term"],
+ color: "black",
+ position: "top",
+ align: "left",
+ },
+ valid: true,
+ },
+
+ {
+ message: {
+ amount: "100", // invalid: should be num
+ offer: ["pay_later_long_term"],
+ color: "black",
+ position: "top",
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: -100, // invalid: should be positive
+ offer: ["pay_later_long_term"],
+ color: "black",
+ position: "top",
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: 100,
+ offer: "pay_later_long_term", // invalid: should be in an array
+ color: "black",
+ position: "top",
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: 100,
+ offer: ["PAY_LATER_LONG_TERM"], // invalid: should be lowercase to match enum values
+ color: "black",
+ position: "top",
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: 100,
+ offer: ["pay_later_long_term"],
+ color: "blue", // invalid: value not in enum
+ position: "top",
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: 100,
+ offer: ["pay_later_long_term"],
+ color: "black",
+ position: "right", // invalid: value not in enum
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: 100,
+ offer: ["pay_later_long_term"],
+ color: "black",
+ position: "top",
+ align: "middle", // invalid: value not in enum
+ },
+ valid: false,
+ },
+
+ {
+ message: {
+ amount: 100,
+ offer: ["pay_later_long_term"],
+ color: "black",
+ position: "bottom", // Message position must be 'top' when Debit and/or Credit Card button is present
+ align: "left",
+ },
+ valid: false,
+ },
+
+ {
+ message: {},
+ valid: true,
+ },
+ ].map(({ message, valid }) => ({
+ desc: `message ${JSON.stringify(message)}`,
+
+ valid,
+
+ conf: {
+ createOrder: noop,
+ onApprove: noop,
+ message,
+ },
+ })),
+ },
];
for (const group of buttonConfigs) {
diff --git a/test/integration/windows/button/index.jsx b/test/integration/windows/button/index.jsx
index 94d0a62ec7..e702f8022d 100644
--- a/test/integration/windows/button/index.jsx
+++ b/test/integration/windows/button/index.jsx
@@ -236,5 +236,11 @@ if (onRender) {
click() {
getElement(".paypal-button", document).click();
},
+ hoverMessage(): ZalgoPromise | void {
+ return window.xprops.onMessageHover();
+ },
+ clickMessage({ offerType, messageType }): ZalgoPromise | void {
+ return window.xprops.onMessageClick({ offerType, messageType });
+ },
});
}
diff --git a/test/integration/windows/button/modal.js b/test/integration/windows/button/modal.js
new file mode 100644
index 0000000000..dde9a898f7
--- /dev/null
+++ b/test/integration/windows/button/modal.js
@@ -0,0 +1,15 @@
+/* @flow */
+const namespace = document.currentScript?.getAttribute("data-pp-namespace");
+
+window.namespace = namespace;
+window[namespace].MessagesModal = (config) => {
+ window[namespace].MessagesModal.mock = {};
+ window[namespace].MessagesModal.mock.calls =
+ (window[namespace].MessagesModal.mock.calls ?? 0) + 1;
+ window[namespace].MessagesModal.mock.calledWith = config;
+ return {
+ show: (config2) => {
+ window[namespace].MessagesModal.mock.show = { calledWith: config2 };
+ },
+ };
+};
diff --git a/webpack.config.dev.js b/webpack.config.dev.js
index 3820a44ee6..3be9d122f0 100644
--- a/webpack.config.dev.js
+++ b/webpack.config.dev.js
@@ -37,6 +37,7 @@ const WEBPACK_CONFIG_DEV: WebpackConfig = getWebpackConfig({
__BUTTONS__: `/demo/dev/button.htm`,
__MENU__: `/demo/dev/menu.htm`,
__CARD_FIELD__: `/demo/dev/cardfield.htm`,
+ __MESSAGE_MODAL__: `https://www.paypalobjects.com/upstream/bizcomponents/js/modal.js`,
},
},
},