diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index 38ef56112..ffb21ffc2 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -13,13 +13,12 @@ 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'; import FormValidator from "./modules/Helper/FormValidator"; import {loadPaypalScript} from "./modules/Helper/ScriptLoading"; -import widgetBuilder from "./modules/Renderer/WidgetBuilder"; // TODO: could be a good idea to have a separate spinner for each gateway, // but I think we care mainly about the script loading, so one spinner should be enough. @@ -268,8 +267,6 @@ document.addEventListener( }); loadPaypalScript(PayPalCommerceGateway, () => { - widgetBuilder.setPaypal(paypal); - bootstrapped = true; bootstrap(); diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js index 5790e8f56..f55663b74 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js @@ -49,12 +49,11 @@ class CartBootstrap { } // handle button status - if ( result.data.button ) { + if (result.data.button) { this.gateway.button = result.data.button; + this.handleButtonStatus(); } - this.handleButtonStatus(); - if (this.lastAmount !== result.data.amount) { this.lastAmount = result.data.amount; this.messages.renderWithAmount(this.lastAmount); diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js index e5ddfb245..f09ef7fda 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js @@ -3,7 +3,7 @@ import SingleProductActionHandler from "../ActionHandler/SingleProductActionHand import {hide, show} from "../Helper/Hiding"; import BootstrapHelper from "../Helper/BootstrapHelper"; import SimulateCart from "../Helper/SimulateCart"; -import {strRemoveWord, strAddWord} from "../Helper/Utils"; +import {strRemoveWord, strAddWord, throttle} from "../Helper/Utils"; class SingleProductBootstap { constructor(gateway, renderer, messages, errorHandler) { @@ -14,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); @@ -46,53 +49,7 @@ class SingleProductBootstap { }); if (simulateCart) { - //------ - - const actionHandler = new SingleProductActionHandler( - null, - null, - this.form(), - this.errorHandler, - ); - - (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; - } - - this.handleButtonStatus(false); - - }, actionHandler.getProducts()); - - //------ + this.simulateCartThrottled(); } } @@ -197,6 +154,52 @@ class SingleProductBootstap { actionHandler.configuration() ); } + + simulateCart() { + const actionHandler = new SingleProductActionHandler( + null, + null, + this.form(), + this.errorHandler, + ); + + (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; + } + + this.handleButtonStatus(false); + + }, actionHandler.getProducts()); + } } export default SingleProductBootstap; diff --git a/modules/ppcp-button/resources/js/modules/DataClientIdAttributeHandler.js b/modules/ppcp-button/resources/js/modules/DataClientIdAttributeHandler.js index 5f70d636b..1d61e3bb5 100644 --- a/modules/ppcp-button/resources/js/modules/DataClientIdAttributeHandler.js +++ b/modules/ppcp-button/resources/js/modules/DataClientIdAttributeHandler.js @@ -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); + } + }); }); } diff --git a/modules/ppcp-button/resources/js/modules/Helper/ScriptLoading.js b/modules/ppcp-button/resources/js/modules/Helper/ScriptLoading.js index c5742ab19..09c95aa87 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/ScriptLoading.js +++ b/modules/ppcp-button/resources/js/modules/Helper/ScriptLoading.js @@ -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); } diff --git a/modules/ppcp-button/resources/js/modules/Helper/Utils.js b/modules/ppcp-button/resources/js/modules/Helper/Utils.js index 565b64863..0e97443b8 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/Utils.js +++ b/modules/ppcp-button/resources/js/modules/Helper/Utils.js @@ -31,11 +31,54 @@ export const strRemoveWord = (str, word, separator = ',') => { return arr.join(separator); }; +export const debounce = (func, wait) => { + let timeout; + return function() { + const context = this; + const args = arguments; + const later = function() { + timeout = null; + func.apply(context, args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +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 + strRemoveWord, + debounce, + throttle }; export default Utils; diff --git a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js index 828134e46..4e6dad65d 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js @@ -74,6 +74,8 @@ class Renderer { renderButtons(wrapper, style, contextConfig, hasEnabledSeparateGateways, fundingSource = null) { 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); return; } @@ -95,19 +97,19 @@ class Renderer { } } - //jQuery(document).off('ppcp-reload-buttons'); - jQuery(document).on('ppcp-reload-buttons', wrapper, (event, settingsOverride = {}) => { - const settings = merge(this.defaultSettings, settingsOverride); - let scriptOptions = keysToCamelCase(settings.url_params); - scriptOptions = merge(scriptOptions, settings.script_attributes); + jQuery(document) + .off('ppcp-reload-buttons', wrapper) + .on('ppcp-reload-buttons', wrapper, (event, settingsOverride = {}) => { + 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, buttonsOptions()); - widgetBuilder.renderAllButtons(); - widgetBuilder.renderAllMessages(); + loadScript(scriptOptions).then((paypal) => { + widgetBuilder.setPaypal(paypal); + widgetBuilder.registerButtons(wrapper, buttonsOptions()); + widgetBuilder.renderAll(); + }); }); - }); this.renderedSources.add(wrapper + (fundingSource ?? '')); diff --git a/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js b/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js index 45d5a0b82..ed45b35b4 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js @@ -30,6 +30,10 @@ class WidgetBuilder { return; } + if (this.hasRendered(wrapper)) { + return; + } + const entry = this.buttons.get(wrapper); const btn = this.paypal.Buttons(entry.options); @@ -58,6 +62,10 @@ class WidgetBuilder { return; } + if (this.hasRendered(wrapper)) { + return; + } + const entry = this.messages.get(wrapper); const btn = this.paypal.Messages(entry.options); @@ -70,6 +78,14 @@ class WidgetBuilder { } } + renderAll() { + this.renderAllButtons(); + this.renderAllMessages(); + } + + hasRendered(wrapper) { + return document.querySelector(wrapper).hasChildNodes(); + } } export default new WidgetBuilder(); diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index 4aae9f12e..c3ea172c8 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -1384,7 +1384,7 @@ class SmartButton implements SmartButtonInterface { * Checks if PayPal buttons/messages should be rendered for the current page. * * @param string|null $context The context that should be checked, use default otherwise. - * @param float|null $price_total The price total to be considered. + * @param float|null $price_total The price total to be considered. * @return bool */ public function is_button_disabled( string $context = null, float $price_total = null ): bool { @@ -1429,7 +1429,7 @@ class SmartButton implements SmartButtonInterface { /** * Checks a filter if pay_later/messages should be rendered on a given location / context. * - * @param string $location The location. + * @param string $location The location. * @param float|null $price_total The price total to be considered. * @return bool */ @@ -1476,7 +1476,7 @@ class SmartButton implements SmartButtonInterface { /** * Check whether Pay Later button is enabled for a given location. * - * @param string $location The location. + * @param string $location The location. * @param float|null $price_total The price total to be considered. * @return bool true if is enabled, otherwise false. */ @@ -1489,7 +1489,7 @@ class SmartButton implements SmartButtonInterface { /** * Check whether Pay Later message is enabled for a given location. * - * @param string $location The location setting name. + * @param string $location The location setting name. * @param float|null $price_total The price total to be considered. * @return bool true if is enabled, otherwise false. */ diff --git a/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php b/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php index 9c518938e..5f61d33b9 100644 --- a/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php @@ -1,4 +1,9 @@ cart->empty_cart( false ); @@ -106,20 +114,20 @@ abstract class AbstractCartEndpoint implements EndpointInterface { foreach ( $products as $product ) { if ( $product['product']->is_type( 'booking' ) ) { $success = $success && $this->add_booking_product( - $product['product'], - $product['booking'] - ); + $product['product'], + $product['booking'] + ); } elseif ( $product['product']->is_type( 'variable' ) ) { $success = $success && $this->add_variable_product( - $product['product'], - $product['quantity'], - $product['variations'] - ); + $product['product'], + $product['quantity'], + $product['variations'] + ); } else { $success = $success && $this->add_product( - $product['product'], - $product['quantity'] - ); + $product['product'], + $product['quantity'] + ); } } @@ -164,6 +172,8 @@ abstract class AbstractCartEndpoint implements EndpointInterface { } /** + * Returns product information from request data. + * * @return array|false */ protected function products_from_request() { @@ -304,6 +314,8 @@ abstract class AbstractCartEndpoint implements EndpointInterface { } /** + * Removes stored cart items from WooCommerce cart. + * * @return void */ protected function remove_cart_items(): void { diff --git a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php index d8c1a3522..44449a761 100644 --- a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php @@ -65,7 +65,7 @@ class CartScriptParamsEndpoint implements EndpointInterface { */ public function handle_request(): bool { try { - if ( is_callable('wc_maybe_define_constant') ) { + if ( is_callable( 'wc_maybe_define_constant' ) ) { wc_maybe_define_constant( 'WOOCOMMERCE_CART', true ); } diff --git a/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php b/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php index 146af5ca4..95979d1b2 100644 --- a/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php @@ -16,7 +16,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; /** * Class ChangeCartEndpoint */ -class ChangeCartEndpoint extends AbstractCartEndpoint { +class ChangeCartEndpoint extends AbstractCartEndpoint { const ENDPOINT = 'ppc-change-cart'; @@ -70,13 +70,15 @@ class ChangeCartEndpoint extends AbstractCartEndpoint { * @throws Exception On error. */ protected function handle_data(): bool { - if ( ! $products = $this->products_from_request() ) { + $products = $this->products_from_request(); + + if ( ! $products ) { return false; } $this->shipping->reset_shipping(); - if ( ! $this->add_products($products) ) { + if ( ! $this->add_products( $products ) ) { return false; } diff --git a/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php index 8e09a25b2..b99b69e8c 100644 --- a/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php @@ -59,40 +59,43 @@ class SimulateCartEndpoint extends AbstractCartEndpoint { * @throws Exception On error. */ protected function handle_data(): bool { - if ( ! $products = $this->products_from_request() ) { + $products = $this->products_from_request(); + + if ( ! $products ) { return false; } // Set WC default cart as the clone. // Store a reference to the real cart. - $activeCart = WC()->cart; - WC()->cart = $this->cart; + $active_cart = WC()->cart; + WC()->cart = $this->cart; - if ( ! $this->add_products($products) ) { + if ( ! $this->add_products( $products ) ) { return false; } $this->cart->calculate_totals(); $total = (float) $this->cart->get_total( 'numeric' ); + // Remove from cart because some plugins reserve resources internally when adding to cart. $this->remove_cart_items(); - // Restore cart and unset cart clone - WC()->cart = $activeCart; + // Restore cart and unset cart clone. + WC()->cart = $active_cart; unset( $this->cart ); wp_send_json_success( array( - 'total' => $total, - 'funding' => [ - 'paylater' => [ - 'enabled' => $this->smart_button->is_pay_later_button_enabled_for_location( 'cart', $total ), + 'total' => $total, + 'funding' => array( + 'paylater' => array( + 'enabled' => $this->smart_button->is_pay_later_button_enabled_for_location( 'cart', $total ), 'messaging_enabled' => $this->smart_button->is_pay_later_messaging_enabled_for_location( 'cart', $total ), - ] - ], - 'button' => [ + ), + ), + 'button' => array( 'is_disabled' => $this->smart_button->is_button_disabled( 'cart', $total ), - ] + ), ) ); return true;