Merge pull request #1511 from woocommerce/PCP-1389-pay-later-button-and-message-get-hidden-when-product-cart-checkout-value-is-outside-of-range

Pay Later button and message get hidden when product/cart/checkout value is outside of range (1389, 1886, 1847)
This commit is contained in:
Emili Castells 2023-07-31 10:34:33 +02:00 committed by GitHub
commit a35bea207e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1113 additions and 371 deletions

View file

@ -13,7 +13,7 @@ import {
ORDER_BUTTON_SELECTOR,
PaymentMethods
} from "./modules/Helper/CheckoutMethodState";
import {hide, setVisible, setVisibleByClass} from "./modules/Helper/Hiding";
import {setVisibleByClass} from "./modules/Helper/Hiding";
import {isChangePaymentPage} from "./modules/Helper/Subscriptions";
import FreeTrialHandler from "./modules/ActionHandler/FreeTrialHandler";
import FormSaver from './modules/Helper/FormSaver';
@ -194,9 +194,6 @@ const bootstrap = () => {
payNowBootstrap.init();
}
if (context !== 'checkout') {
messageRenderer.render();
}
};
document.addEventListener(
'DOMContentLoaded',

View file

@ -40,8 +40,7 @@ class SingleProductActionHandler {
}).then((res)=>{
return res.json();
}).then(() => {
const id = document.querySelector('[name="add-to-cart"]').value;
const products = [new Product(id, 1, null)];
const products = this.getSubscriptionProducts();
fetch(this.config.ajax.change_cart.endpoint, {
method: 'POST',
@ -71,6 +70,12 @@ class SingleProductActionHandler {
}
}
getSubscriptionProducts()
{
const id = document.querySelector('[name="add-to-cart"]').value;
return [new Product(id, 1, null)];
}
configuration()
{
return {
@ -98,43 +103,38 @@ class SingleProductActionHandler {
}
}
getProducts()
{
if ( this.isBookingProduct() ) {
const id = document.querySelector('[name="add-to-cart"]').value;
return [new BookingProduct(id, 1, FormHelper.getPrefixedFields(this.formElement, "wc_bookings_field"))];
} else if ( this.isGroupedProduct() ) {
const products = [];
this.formElement.querySelectorAll('input[type="number"]').forEach((element) => {
if (! element.value) {
return;
}
const elementName = element.getAttribute('name').match(/quantity\[([\d]*)\]/);
if (elementName.length !== 2) {
return;
}
const id = parseInt(elementName[1]);
const quantity = parseInt(element.value);
products.push(new Product(id, quantity, null));
})
return products;
} else {
const id = document.querySelector('[name="add-to-cart"]').value;
const qty = document.querySelector('[name="quantity"]').value;
const variations = this.variations();
return [new Product(id, qty, variations)];
}
}
createOrder()
{
this.cartHelper = null;
let getProducts = (() => {
if ( this.isBookingProduct() ) {
return () => {
const id = document.querySelector('[name="add-to-cart"]').value;
return [new BookingProduct(id, 1, FormHelper.getPrefixedFields(this.formElement, "wc_bookings_field"))];
}
} else if ( this.isGroupedProduct() ) {
return () => {
const products = [];
this.formElement.querySelectorAll('input[type="number"]').forEach((element) => {
if (! element.value) {
return;
}
const elementName = element.getAttribute('name').match(/quantity\[([\d]*)\]/);
if (elementName.length !== 2) {
return;
}
const id = parseInt(elementName[1]);
const quantity = parseInt(element.value);
products.push(new Product(id, quantity, null));
})
return products;
}
} else {
return () => {
const id = document.querySelector('[name="add-to-cart"]').value;
const qty = document.querySelector('[name="quantity"]').value;
const variations = this.variations();
return [new Product(id, qty, variations)];
}
}
})();
return (data, actions) => {
this.errorHandler.clear();
@ -170,7 +170,7 @@ class SingleProductActionHandler {
});
};
return this.updateCart.update(onResolve, getProducts());
return this.updateCart.update(onResolve, this.getProducts());
};
}

View file

@ -1,6 +1,5 @@
import CartActionHandler from '../ActionHandler/CartActionHandler';
import BootstrapHelper from "../Helper/BootstrapHelper";
import {setVisible} from "../Helper/Hiding";
class CartBootstrap {
constructor(gateway, renderer, messages, errorHandler) {
@ -40,11 +39,21 @@ class CartBootstrap {
return;
}
// handle script reload
const newParams = result.data.url_params;
const reloadRequired = this.gateway.url_params.intent !== newParams.intent;
const reloadRequired = JSON.stringify(this.gateway.url_params) !== JSON.stringify(newParams);
// TODO: should reload the script instead
setVisible(this.gateway.button.wrapper, !reloadRequired)
if (reloadRequired) {
this.gateway.url_params = newParams;
jQuery(this.gateway.button.wrapper).trigger('ppcp-reload-buttons');
}
// handle button status
if (result.data.button || result.data.messages) {
this.gateway.button = result.data.button;
this.gateway.messages = result.data.messages;
this.handleButtonStatus();
}
if (this.lastAmount !== result.data.amount) {
this.lastAmount = result.data.amount;

View file

@ -6,7 +6,6 @@ import {
PaymentMethods
} from "../Helper/CheckoutMethodState";
import BootstrapHelper from "../Helper/BootstrapHelper";
import {disable, enable} from "../Helper/ButtonDisabler";
class CheckoutBootstap {
constructor(gateway, renderer, messages, spinner, errorHandler) {

View file

@ -2,6 +2,8 @@ import UpdateCart from "../Helper/UpdateCart";
import SingleProductActionHandler from "../ActionHandler/SingleProductActionHandler";
import {hide, show} from "../Helper/Hiding";
import BootstrapHelper from "../Helper/BootstrapHelper";
import SimulateCart from "../Helper/SimulateCart";
import {strRemoveWord, strAddWord, throttle} from "../Helper/Utils";
class SingleProductBootstap {
constructor(gateway, renderer, messages, errorHandler) {
@ -12,6 +14,9 @@ class SingleProductBootstap {
this.mutationObserver = new MutationObserver(this.handleChange.bind(this));
this.formSelector = 'form.cart';
// Prevent simulate cart being called too many times in a burst.
this.simulateCartThrottled = throttle(this.simulateCart, 5000);
this.renderer.onButtonsInit(this.gateway.button.wrapper, () => {
this.handleChange();
}, true);
@ -38,10 +43,14 @@ class SingleProductBootstap {
this.handleButtonStatus();
}
handleButtonStatus() {
handleButtonStatus(simulateCart = true) {
BootstrapHelper.handleButtonStatus(this, {
formSelector: this.formSelector
});
if (simulateCart) {
this.simulateCartThrottled();
}
}
init() {
@ -53,12 +62,6 @@ class SingleProductBootstap {
jQuery(document).on('change', this.formSelector, () => {
this.handleChange();
setTimeout(() => { // Wait for the DOM to be fully updated
// For the moment renderWithAmount should only be done here to prevent undesired side effects due to priceAmount()
// not being correctly formatted in some cases, can be moved to handleButtonStatus() once this issue is fixed
this.messages.renderWithAmount(this.priceAmount());
}, 100);
});
this.mutationObserver.observe(form, { childList: true, subtree: true });
@ -161,6 +164,62 @@ class SingleProductBootstap {
actionHandler.configuration()
);
}
simulateCart() {
const actionHandler = new SingleProductActionHandler(
null,
null,
this.form(),
this.errorHandler,
);
const hasSubscriptions = PayPalCommerceGateway.data_client_id.has_subscriptions
&& PayPalCommerceGateway.data_client_id.paypal_subscriptions_enabled;
const products = hasSubscriptions
? actionHandler.getSubscriptionProducts()
: actionHandler.getProducts();
(new SimulateCart(
this.gateway.ajax.simulate_cart.endpoint,
this.gateway.ajax.simulate_cart.nonce,
)).simulate((data) => {
this.messages.renderWithAmount(data.total);
let enableFunding = this.gateway.url_params['enable-funding'];
let disableFunding = this.gateway.url_params['disable-funding'];
for (const [fundingSource, funding] of Object.entries(data.funding)) {
if (funding.enabled === true) {
enableFunding = strAddWord(enableFunding, fundingSource);
disableFunding = strRemoveWord(disableFunding, fundingSource);
} else if (funding.enabled === false) {
enableFunding = strRemoveWord(enableFunding, fundingSource);
disableFunding = strAddWord(disableFunding, fundingSource);
}
}
if (
(enableFunding !== this.gateway.url_params['enable-funding']) ||
(disableFunding !== this.gateway.url_params['disable-funding'])
) {
this.gateway.url_params['enable-funding'] = enableFunding;
this.gateway.url_params['disable-funding'] = disableFunding;
jQuery(this.gateway.button.wrapper).trigger('ppcp-reload-buttons');
}
if (typeof data.button.is_disabled === 'boolean') {
this.gateway.button.is_disabled = data.button.is_disabled;
}
if (typeof data.messages.is_hidden === 'boolean') {
this.gateway.messages.is_hidden = data.messages.is_hidden;
}
this.handleButtonStatus(false);
}, products);
}
}
export default SingleProductBootstap;

View file

@ -1,3 +1,6 @@
import {loadScript} from "@paypal/paypal-js";
import widgetBuilder from "./Renderer/WidgetBuilder";
const storageKey = 'ppcp-data-client-id';
const validateToken = (token, user) => {
@ -24,7 +27,7 @@ const storeToken = (token) => {
sessionStorage.setItem(storageKey, JSON.stringify(token));
}
const dataClientIdAttributeHandler = (script, config) => {
const dataClientIdAttributeHandler = (scriptOptions, config, callback) => {
fetch(config.endpoint, {
method: 'POST',
headers: {
@ -42,8 +45,14 @@ const dataClientIdAttributeHandler = (script, config) => {
return;
}
storeToken(data);
script.setAttribute('data-client-token', data.token);
document.body.appendChild(script);
scriptOptions['data-client-token'] = data.token;
loadScript(scriptOptions).then((paypal) => {
if (typeof callback === 'function') {
callback(paypal);
}
});
});
}

View file

@ -1,4 +1,5 @@
import {disable, enable} from "./ButtonDisabler";
import {hide, show} from "./Hiding";
/**
* Common Bootstrap methods to avoid code repetition.
@ -11,20 +12,28 @@ export default class BootstrapHelper {
options.messagesWrapper = options.messagesWrapper || bs.gateway.messages.wrapper;
options.skipMessages = options.skipMessages || false;
if (!bs.shouldEnable()) {
// Handle messages hide / show
if (this.shouldShowMessages(bs, options)) {
show(options.messagesWrapper);
} else {
hide(options.messagesWrapper);
}
// Handle enable / disable
if (bs.shouldEnable()) {
bs.renderer.enableSmartButtons(options.wrapper);
enable(options.wrapper);
if (!options.skipMessages) {
enable(options.messagesWrapper);
}
} else {
bs.renderer.disableSmartButtons(options.wrapper);
disable(options.wrapper, options.formSelector || null);
if (!options.skipMessages) {
disable(options.messagesWrapper);
}
return;
}
bs.renderer.enableSmartButtons(options.wrapper);
enable(options.wrapper);
if (!options.skipMessages) {
enable(options.messagesWrapper);
}
}
@ -37,4 +46,13 @@ export default class BootstrapHelper {
return bs.shouldRender()
&& options.isDisabled !== true;
}
static shouldShowMessages(bs, options) {
options = options || {};
if (typeof options.isMessagesHidden === 'undefined') {
options.isMessagesHidden = bs.gateway.messages.is_hidden;
}
return options.isMessagesHidden !== true;
}
}

View file

@ -1,4 +1,8 @@
import dataClientIdAttributeHandler from "../DataClientIdAttributeHandler";
import {loadScript} from "@paypal/paypal-js";
import widgetBuilder from "../Renderer/WidgetBuilder";
import merge from "deepmerge";
import {keysToCamelCase} from "./Utils";
export const loadPaypalScript = (config, onLoaded) => {
if (typeof paypal !== 'undefined') {
@ -6,19 +10,18 @@ export const loadPaypalScript = (config, onLoaded) => {
return;
}
const script = document.createElement('script');
script.addEventListener('load', onLoaded);
script.setAttribute('src', config.url);
Object.entries(config.script_attributes).forEach(
(keyValue) => {
script.setAttribute(keyValue[0], keyValue[1]);
}
);
const callback = (paypal) => {
widgetBuilder.setPaypal(paypal);
onLoaded();
}
let scriptOptions = keysToCamelCase(config.url_params);
scriptOptions = merge(scriptOptions, config.script_attributes);
if (config.data_client_id.set_attribute) {
dataClientIdAttributeHandler(script, config.data_client_id);
dataClientIdAttributeHandler(scriptOptions, config.data_client_id, callback);
return;
}
document.body.appendChild(script);
loadScript(scriptOptions).then(callback);
}

View file

@ -0,0 +1,48 @@
class SimulateCart {
constructor(endpoint, nonce)
{
this.endpoint = endpoint;
this.nonce = nonce;
}
/**
*
* @param onResolve
* @param {Product[]} products
* @returns {Promise<unknown>}
*/
simulate(onResolve, products)
{
return new Promise((resolve, reject) => {
fetch(
this.endpoint,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({
nonce: this.nonce,
products,
})
}
).then(
(result) => {
return result.json();
}
).then((result) => {
if (! result.success) {
reject(result.data);
return;
}
const resolved = onResolve(result.data);
resolve(resolved);
})
});
}
}
export default SimulateCart;

View file

@ -0,0 +1,69 @@
export const toCamelCase = (str) => {
return str.replace(/([-_]\w)/g, function(match) {
return match[1].toUpperCase();
});
}
export const keysToCamelCase = (obj) => {
let output = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
output[toCamelCase(key)] = obj[key];
}
}
return output;
}
export const strAddWord = (str, word, separator = ',') => {
let arr = str.split(separator);
if (!arr.includes(word)) {
arr.push(word);
}
return arr.join(separator);
};
export const strRemoveWord = (str, word, separator = ',') => {
let arr = str.split(separator);
let index = arr.indexOf(word);
if (index !== -1) {
arr.splice(index, 1);
}
return arr.join(separator);
};
export const throttle = (func, limit) => {
let inThrottle, lastArgs, lastContext;
function execute() {
inThrottle = true;
func.apply(this, arguments);
setTimeout(() => {
inThrottle = false;
if (lastArgs) {
const nextArgs = lastArgs;
const nextContext = lastContext;
lastArgs = lastContext = null;
execute.apply(nextContext, nextArgs);
}
}, limit);
}
return function() {
if (!inThrottle) {
execute.apply(this, arguments);
} else {
lastArgs = arguments;
lastContext = this;
}
};
}
const Utils = {
toCamelCase,
keysToCamelCase,
strAddWord,
strRemoveWord,
throttle
};
export default Utils;

View file

@ -1,3 +1,5 @@
import widgetBuilder from "./WidgetBuilder";
class MessageRenderer {
constructor(config) {
@ -28,7 +30,8 @@ class MessageRenderer {
oldWrapper.parentElement.removeChild(oldWrapper);
sibling.parentElement.insertBefore(newWrapper, sibling);
paypal.Messages(options).render(this.config.wrapper);
widgetBuilder.registerMessages(this.config.wrapper, options);
widgetBuilder.renderMessages(this.config.wrapper);
}
optionsEqual(options) {
@ -44,7 +47,7 @@ class MessageRenderer {
shouldRender() {
if (typeof paypal.Messages === 'undefined' || typeof this.config.wrapper === 'undefined' ) {
if (typeof paypal === 'undefined' || typeof paypal.Messages === 'undefined' || typeof this.config.wrapper === 'undefined' ) {
return false;
}
if (! document.querySelector(this.config.wrapper)) {

View file

@ -1,4 +1,7 @@
import merge from "deepmerge";
import {loadScript} from "@paypal/paypal-js";
import {keysToCamelCase} from "../Helper/Utils";
import widgetBuilder from "./WidgetBuilder";
class Renderer {
constructor(creditCardRenderer, defaultSettings, onSmartButtonClick, onSmartButtonsInit) {
@ -11,6 +14,8 @@ class Renderer {
this.onButtonsInitListeners = {};
this.renderedSources = new Set();
this.reloadEventName = 'ppcp-reload-buttons';
}
render(contextConfig, settingsOverride = {}, contextConfigOverride = () => {}) {
@ -68,7 +73,9 @@ class Renderer {
}
renderButtons(wrapper, style, contextConfig, hasEnabledSeparateGateways, fundingSource = null) {
if (! document.querySelector(wrapper) || this.isAlreadyRendered(wrapper, fundingSource, hasEnabledSeparateGateways) || 'undefined' === typeof paypal.Buttons ) {
if (! document.querySelector(wrapper) || this.isAlreadyRendered(wrapper, fundingSource, hasEnabledSeparateGateways) ) {
// Try to render registered buttons again in case they were removed from the DOM by an external source.
widgetBuilder.renderButtons([wrapper, fundingSource]);
return;
}
@ -76,35 +83,50 @@ class Renderer {
contextConfig.fundingSource = fundingSource;
}
const btn = paypal.Buttons({
style,
...contextConfig,
onClick: this.onSmartButtonClick,
onInit: (data, actions) => {
if (this.onSmartButtonsInit) {
this.onSmartButtonsInit(data, actions);
}
this.handleOnButtonsInit(wrapper, data, actions);
},
});
if (!btn.isEligible()) {
return;
const buttonsOptions = () => {
return {
style,
...contextConfig,
onClick: this.onSmartButtonClick,
onInit: (data, actions) => {
if (this.onSmartButtonsInit) {
this.onSmartButtonsInit(data, actions);
}
this.handleOnButtonsInit(wrapper, data, actions);
},
}
}
btn.render(wrapper);
jQuery(document)
.off(this.reloadEventName, wrapper)
.on(this.reloadEventName, wrapper, (event, settingsOverride = {}, triggeredFundingSource) => {
this.renderedSources.add(wrapper + fundingSource ?? '');
// Only accept events from the matching funding source
if (fundingSource && triggeredFundingSource && (triggeredFundingSource !== fundingSource)) {
return;
}
const settings = merge(this.defaultSettings, settingsOverride);
let scriptOptions = keysToCamelCase(settings.url_params);
scriptOptions = merge(scriptOptions, settings.script_attributes);
loadScript(scriptOptions).then((paypal) => {
widgetBuilder.setPaypal(paypal);
widgetBuilder.registerButtons([wrapper, fundingSource], buttonsOptions());
widgetBuilder.renderAll();
});
});
this.renderedSources.add(wrapper + (fundingSource ?? ''));
if (typeof paypal !== 'undefined' && typeof paypal.Buttons !== 'undefined') {
widgetBuilder.registerButtons([wrapper, fundingSource], buttonsOptions());
widgetBuilder.renderButtons([wrapper, fundingSource]);
}
}
isAlreadyRendered(wrapper, fundingSource, hasEnabledSeparateGateways) {
// Simply check that has child nodes when we do not need to render buttons separately,
// this will reduce the risk of breaking with different themes/plugins
// and on the cart page (where we also do not need to render separately), which may fully reload this part of the page.
// Ideally we should also find a way to detect such full reloads and remove the corresponding keys from the set.
if (!hasEnabledSeparateGateways) {
return document.querySelector(wrapper).hasChildNodes();
}
return this.renderedSources.has(wrapper + fundingSource ?? '');
isAlreadyRendered(wrapper, fundingSource) {
return this.renderedSources.has(wrapper + (fundingSource ?? ''));
}
disableCreditCardFields() {

View file

@ -0,0 +1,177 @@
/**
* Handles the registration and rendering of PayPal widgets: Buttons and Messages.
* To have several Buttons per wrapper, an array should be provided, ex: [wrapper, fundingSource].
*/
class WidgetBuilder {
constructor() {
this.paypal = null;
this.buttons = new Map();
this.messages = new Map();
this.renderEventName = 'ppcp-render';
document.ppcpWidgetBuilderStatus = () => {
console.log({
buttons: this.buttons,
messages: this.messages,
});
}
jQuery(document)
.off(this.renderEventName)
.on(this.renderEventName, () => {
this.renderAll();
});
}
setPaypal(paypal) {
this.paypal = paypal;
}
registerButtons(wrapper, options) {
wrapper = this.sanitizeWrapper(wrapper);
this.buttons.set(this.toKey(wrapper), {
wrapper: wrapper,
options: options,
});
}
renderButtons(wrapper) {
wrapper = this.sanitizeWrapper(wrapper);
if (!this.buttons.has(this.toKey(wrapper))) {
return;
}
if (this.hasRendered(wrapper)) {
return;
}
const entry = this.buttons.get(this.toKey(wrapper));
const btn = this.paypal.Buttons(entry.options);
if (!btn.isEligible()) {
this.buttons.delete(this.toKey(wrapper));
return;
}
let target = this.buildWrapperTarget(wrapper);
if (!target) {
return;
}
btn.render(target);
}
renderAllButtons() {
for (const [wrapper, entry] of this.buttons) {
this.renderButtons(wrapper);
}
}
registerMessages(wrapper, options) {
this.messages.set(wrapper, {
wrapper: wrapper,
options: options
});
}
renderMessages(wrapper) {
if (!this.messages.has(wrapper)) {
return;
}
if (this.hasRendered(wrapper)) {
return;
}
const entry = this.messages.get(wrapper);
const btn = this.paypal.Messages(entry.options);
btn.render(entry.wrapper);
// watchdog to try to handle some strange cases where the wrapper may not be present
setTimeout(() => {
if (!this.hasRendered(wrapper)) {
btn.render(entry.wrapper);
}
}, 100);
}
renderAllMessages() {
for (const [wrapper, entry] of this.messages) {
this.renderMessages(wrapper);
}
}
renderAll() {
this.renderAllButtons();
this.renderAllMessages();
}
hasRendered(wrapper) {
let selector = wrapper;
if (Array.isArray(wrapper)) {
selector = wrapper[0];
for (const item of wrapper.slice(1)) {
selector += ' .item-' + item;
}
}
const element = document.querySelector(selector);
return element && element.hasChildNodes();
}
sanitizeWrapper(wrapper) {
if (Array.isArray(wrapper)) {
wrapper = wrapper.filter(item => !!item);
if (wrapper.length === 1) {
wrapper = wrapper[0];
}
}
return wrapper;
}
buildWrapperTarget(wrapper) {
let target = wrapper;
if (Array.isArray(wrapper)) {
const $wrapper = jQuery(wrapper[0]);
if (!$wrapper.length) {
return;
}
const itemClass = 'item-' + wrapper[1];
// Check if the parent element exists and it doesn't already have the div with the class
let $item = $wrapper.find('.' + itemClass);
if (!$item.length) {
$item = jQuery(`<div class="${itemClass}"></div>`);
$wrapper.append($item);
}
target = $item.get(0);
}
if (!jQuery(target).length) {
return null;
}
return target;
}
toKey(wrapper) {
if (Array.isArray(wrapper)) {
return JSON.stringify(wrapper);
}
return wrapper;
}
}
export default new WidgetBuilder();