diff --git a/modules/ppcp-button/package.json b/modules/ppcp-button/package.json index 61e31d70f..77828e436 100644 --- a/modules/ppcp-button/package.json +++ b/modules/ppcp-button/package.json @@ -11,9 +11,10 @@ "Edge >= 14" ], "dependencies": { + "@paypal/paypal-js": "^6.0.0", "core-js": "^3.25.0", - "formdata-polyfill": "^4.0.10", - "deepmerge": "^4.2.2" + "deepmerge": "^4.2.2", + "formdata-polyfill": "^4.0.10" }, "devDependencies": { "@babel/core": "^7.19", diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index 790030920..ffb21ffc2 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -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', diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js index 5cb64febc..abc07c594 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js @@ -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()); }; } diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js index 5aadb7f61..f02b628ee 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js @@ -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; diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js index db1cba84f..85fdab285 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js @@ -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) { diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js index 9627c0ca8..484673d71 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js @@ -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; 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/BootstrapHelper.js b/modules/ppcp-button/resources/js/modules/Helper/BootstrapHelper.js index ba00e2c76..8ba3d0e09 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/BootstrapHelper.js +++ b/modules/ppcp-button/resources/js/modules/Helper/BootstrapHelper.js @@ -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; + } } 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/SimulateCart.js b/modules/ppcp-button/resources/js/modules/Helper/SimulateCart.js new file mode 100644 index 000000000..106dfe989 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/SimulateCart.js @@ -0,0 +1,48 @@ +class SimulateCart { + + constructor(endpoint, nonce) + { + this.endpoint = endpoint; + this.nonce = nonce; + } + + /** + * + * @param onResolve + * @param {Product[]} products + * @returns {Promise} + */ + 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; diff --git a/modules/ppcp-button/resources/js/modules/Helper/Utils.js b/modules/ppcp-button/resources/js/modules/Helper/Utils.js new file mode 100644 index 000000000..c1fbb8aec --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/Utils.js @@ -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; diff --git a/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js b/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js index 7b1577aad..afbb55efa 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js @@ -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)) { diff --git a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js index ae0997c17..1fd2e22e3 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js @@ -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() { diff --git a/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js b/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js new file mode 100644 index 000000000..07d7c057c --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Renderer/WidgetBuilder.js @@ -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(`
`); + $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(); diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 56381fcc2..88ee20dd0 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\Button; use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveSubscriptionEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CartScriptParamsEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\SimulateCartEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\CheckoutFormSaver; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; use WooCommerce\PayPalCommerce\Button\Validation\CheckoutFormValidator; @@ -124,6 +125,17 @@ return array( 'button.request-data' => static function ( ContainerInterface $container ): RequestData { return new RequestData(); }, + 'button.endpoint.simulate-cart' => static function ( ContainerInterface $container ): SimulateCartEndpoint { + if ( ! \WC()->cart ) { + throw new RuntimeException( 'cant initialize endpoint at this moment' ); + } + $smart_button = $container->get( 'button.smart-button' ); + $cart = WC()->cart; + $request_data = $container->get( 'button.request-data' ); + $data_store = \WC_Data_Store::load( 'product' ); + $logger = $container->get( 'woocommerce.logger.woocommerce' ); + return new SimulateCartEndpoint( $smart_button, $cart, $request_data, $data_store, $logger ); + }, 'button.endpoint.change-cart' => static function ( ContainerInterface $container ): ChangeCartEndpoint { if ( ! \WC()->cart ) { throw new RuntimeException( 'cant initialize endpoint at this moment' ); diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index fded20a67..b40bfd266 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -25,6 +25,7 @@ use WooCommerce\PayPalCommerce\Button\Endpoint\CreateOrderEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\DataClientIdEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\RequestData; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\SimulateCartEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\StartPayPalVaultingEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint; use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; @@ -683,6 +684,7 @@ class SmartButton implements SmartButtonInterface { return array( 'wrapper' => '#ppcp-messages', + 'is_hidden' => ! $this->is_pay_later_filter_enabled_for_location( $this->context() ), 'amount' => $amount, 'placement' => $placement, 'style' => array( @@ -827,6 +829,10 @@ class SmartButton implements SmartButtonInterface { 'redirect' => wc_get_checkout_url(), 'context' => $this->context(), 'ajax' => array( + 'simulate_cart' => array( + 'endpoint' => \WC_AJAX::get_endpoint( SimulateCartEndpoint::ENDPOINT ), + 'nonce' => wp_create_nonce( SimulateCartEndpoint::nonce() ), + ), 'change_cart' => array( 'endpoint' => \WC_AJAX::get_endpoint( ChangeCartEndpoint::ENDPOINT ), 'nonce' => wp_create_nonce( ChangeCartEndpoint::nonce() ), @@ -1073,8 +1079,8 @@ class SmartButton implements SmartButtonInterface { $enable_funding = array( 'venmo' ); - if ( $this->settings_status->is_pay_later_button_enabled_for_location( $context ) || - $this->settings_status->is_pay_later_messaging_enabled_for_location( $context ) + if ( $this->is_pay_later_button_enabled_for_location( $context ) || + $this->is_pay_later_messaging_enabled_for_location( $context ) ) { $enable_funding[] = 'paylater'; } else { @@ -1363,49 +1369,112 @@ class SmartButton implements SmartButtonInterface { ); } + /** + * Fills and returns the product context_data array to be used in filters. + * + * @param array $context_data The context data for this filter. + * @return array + */ + private function product_filter_context_data( array $context_data = array() ): array { + if ( ! isset( $context_data['product'] ) ) { + $context_data['product'] = wc_get_product(); + } + if ( ! $context_data['product'] ) { + return array(); + } + if ( ! isset( $context_data['order_total'] ) && ( $context_data['product'] instanceof WC_Product ) ) { + $context_data['order_total'] = (float) $context_data['product']->get_price( 'raw' ); + } + + return $context_data; + } + /** * 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 array $context_data The context data for this filter. * @return bool */ - protected function is_button_disabled( string $context = null ): bool { + public function is_button_disabled( string $context = null, array $context_data = array() ): bool { if ( null === $context ) { $context = $this->context(); } if ( 'product' === $context ) { - $product = wc_get_product(); - /** - * Allows to decide if the button should be disabled for a given product + * Allows to decide if the button should be disabled for a given product. */ - $is_disabled = apply_filters( + return apply_filters( 'woocommerce_paypal_payments_product_buttons_disabled', - null, - $product + false, + $this->product_filter_context_data( $context_data ) ); - - if ( $is_disabled !== null ) { - return $is_disabled; - } } /** - * Allows to decide if the button should be disabled globally or on a given context + * Allows to decide if the button should be disabled globally or on a given context. */ - $is_disabled = apply_filters( + return apply_filters( 'woocommerce_paypal_payments_buttons_disabled', - null, + false, $context ); + } - if ( $is_disabled !== null ) { - return $is_disabled; + /** + * Checks a filter if pay_later/messages should be rendered on a given location / context. + * + * @param string $location The location. + * @param array $context_data The context data for this filter. + * @return bool + */ + private function is_pay_later_filter_enabled_for_location( string $location, array $context_data = array() ): bool { + + if ( 'product' === $location ) { + /** + * Allows to decide if the button should be disabled for a given product. + */ + return ! apply_filters( + 'woocommerce_paypal_payments_product_buttons_paylater_disabled', + false, + $this->product_filter_context_data( $context_data ) + ); } - return false; + /** + * Allows to decide if the button should be disabled on a given context. + */ + return ! apply_filters( + 'woocommerce_paypal_payments_buttons_paylater_disabled', + false, + $location + ); + } + + /** + * Check whether Pay Later button is enabled for a given location. + * + * @param string $location The location. + * @param array $context_data The context data for this filter. + * @return bool true if is enabled, otherwise false. + */ + public function is_pay_later_button_enabled_for_location( string $location, array $context_data = array() ): bool { + return $this->is_pay_later_filter_enabled_for_location( $location, $context_data ) + && $this->settings_status->is_pay_later_button_enabled_for_location( $location ); + + } + + /** + * Check whether Pay Later message is enabled for a given location. + * + * @param string $location The location setting name. + * @param array $context_data The context data for this filter. + * @return bool true if is enabled, otherwise false. + */ + public function is_pay_later_messaging_enabled_for_location( string $location, array $context_data = array() ): bool { + return $this->is_pay_later_filter_enabled_for_location( $location, $context_data ) + && $this->settings_status->is_pay_later_messaging_enabled_for_location( $location ); } /** diff --git a/modules/ppcp-button/src/ButtonModule.php b/modules/ppcp-button/src/ButtonModule.php index 2c7337297..67dec01f3 100644 --- a/modules/ppcp-button/src/ButtonModule.php +++ b/modules/ppcp-button/src/ButtonModule.php @@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\Button; use WooCommerce\PayPalCommerce\Button\Endpoint\ApproveSubscriptionEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\CartScriptParamsEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\SaveCheckoutFormEndpoint; +use WooCommerce\PayPalCommerce\Button\Endpoint\SimulateCartEndpoint; use WooCommerce\PayPalCommerce\Button\Endpoint\ValidateCheckoutEndpoint; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; @@ -120,6 +121,19 @@ class ButtonModule implements ModuleInterface { } ); + add_action( + 'wc_ajax_' . SimulateCartEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'button.endpoint.simulate-cart' ); + /** + * The Simulate Cart Endpoint. + * + * @var SimulateCartEndpoint $endpoint + */ + $endpoint->handle_request(); + } + ); + add_action( 'wc_ajax_' . ChangeCartEndpoint::ENDPOINT, static function () use ( $container ) { diff --git a/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php b/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php new file mode 100644 index 000000000..5f61d33b9 --- /dev/null +++ b/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php @@ -0,0 +1,327 @@ +handle_data(); + } catch ( Exception $error ) { + $this->logger->error( 'Cart ' . $this->logger_tag . ' failed: ' . $error->getMessage() ); + + wp_send_json_error( + array( + 'name' => is_a( $error, PayPalApiException::class ) ? $error->name() : '', + 'message' => $error->getMessage(), + 'code' => $error->getCode(), + 'details' => is_a( $error, PayPalApiException::class ) ? $error->details() : array(), + ) + ); + return false; + } + } + + /** + * Handles the request data. + * + * @return bool + * @throws Exception On error. + */ + abstract protected function handle_data(): bool; + + /** + * Adds products to cart. + * + * @param array $products Array of products to be added to cart. + * @return bool + * @throws Exception Add to cart methods throw an exception on fail. + */ + protected function add_products( array $products ): bool { + $this->cart->empty_cart( false ); + + $success = true; + foreach ( $products as $product ) { + if ( $product['product']->is_type( 'booking' ) ) { + $success = $success && $this->add_booking_product( + $product['product'], + $product['booking'] + ); + } elseif ( $product['product']->is_type( 'variable' ) ) { + $success = $success && $this->add_variable_product( + $product['product'], + $product['quantity'], + $product['variations'] + ); + } else { + $success = $success && $this->add_product( + $product['product'], + $product['quantity'] + ); + } + } + + if ( ! $success ) { + $this->handle_error(); + } + + return $success; + } + + /** + * Handles errors. + * + * @return void + */ + private function handle_error(): void { + + $message = __( + 'Something went wrong. Action aborted', + 'woocommerce-paypal-payments' + ); + $errors = wc_get_notices( 'error' ); + if ( count( $errors ) ) { + $message = array_reduce( + $errors, + static function ( string $add, array $error ): string { + return $add . $error['notice'] . ' '; + }, + '' + ); + wc_clear_notices(); + } + + wp_send_json_error( + array( + 'name' => '', + 'message' => $message, + 'code' => 0, + 'details' => array(), + ) + ); + } + + /** + * Returns product information from request data. + * + * @return array|false + */ + protected function products_from_request() { + $data = $this->request_data->read_request( $this->nonce() ); + $products = $this->products_from_data( $data ); + if ( ! $products ) { + wp_send_json_error( + array( + 'name' => '', + 'message' => __( + 'Necessary fields not defined. Action aborted.', + 'woocommerce-paypal-payments' + ), + 'code' => 0, + 'details' => array(), + ) + ); + return false; + } + + return $products; + } + + /** + * Returns product information from a data array. + * + * @param array $data The data array. + * + * @return array|null + */ + protected function products_from_data( array $data ): ?array { + + $products = array(); + + if ( + ! isset( $data['products'] ) + || ! is_array( $data['products'] ) + ) { + return null; + } + foreach ( $data['products'] as $product ) { + if ( ! isset( $product['quantity'] ) || ! isset( $product['id'] ) ) { + return null; + } + + $wc_product = wc_get_product( (int) $product['id'] ); + + if ( ! $wc_product ) { + return null; + } + $products[] = array( + 'product' => $wc_product, + 'quantity' => (int) $product['quantity'], + 'variations' => $product['variations'] ?? null, + 'booking' => $product['booking'] ?? null, + ); + } + return $products; + } + + /** + * Adds a product to the cart. + * + * @param \WC_Product $product The Product. + * @param int $quantity The Quantity. + * + * @return bool + * @throws Exception When product could not be added. + */ + private function add_product( \WC_Product $product, int $quantity ): bool { + $cart_item_key = $this->cart->add_to_cart( $product->get_id(), $quantity ); + + $this->cart_item_keys[] = $cart_item_key; + return false !== $cart_item_key; + } + + /** + * Adds variations to the cart. + * + * @param \WC_Product $product The Product. + * @param int $quantity The Quantity. + * @param array $post_variations The variations. + * + * @return bool + * @throws Exception When product could not be added. + */ + private function add_variable_product( + \WC_Product $product, + int $quantity, + array $post_variations + ): bool { + + $variations = array(); + foreach ( $post_variations as $key => $value ) { + $variations[ $value['name'] ] = $value['value']; + } + + $variation_id = $this->product_data_store->find_matching_product_variation( $product, $variations ); + + // ToDo: Check stock status for variation. + $cart_item_key = $this->cart->add_to_cart( + $product->get_id(), + $quantity, + $variation_id, + $variations + ); + + $this->cart_item_keys[] = $cart_item_key; + return false !== $cart_item_key; + } + + /** + * Adds booking to the cart. + * + * @param \WC_Product $product The Product. + * @param array $data Data used by the booking plugin. + * + * @return bool + * @throws Exception When product could not be added. + */ + private function add_booking_product( + \WC_Product $product, + array $data + ): bool { + + if ( ! is_callable( 'wc_bookings_get_posted_data' ) ) { + return false; + } + + $cart_item_data = array( + 'booking' => wc_bookings_get_posted_data( $data, $product ), + ); + + $cart_item_key = $this->cart->add_to_cart( $product->get_id(), 1, 0, array(), $cart_item_data ); + + $this->cart_item_keys[] = $cart_item_key; + return false !== $cart_item_key; + } + + /** + * Removes stored cart items from WooCommerce cart. + * + * @return void + */ + protected function remove_cart_items(): void { + foreach ( $this->cart_item_keys as $cart_item_key ) { + $this->cart->remove_cart_item( $cart_item_key ); + } + } + +} diff --git a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php index 440af66f1..58350f6f8 100644 --- a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php @@ -65,11 +65,17 @@ class CartScriptParamsEndpoint implements EndpointInterface { */ public function handle_request(): bool { try { + if ( is_callable( 'wc_maybe_define_constant' ) ) { + wc_maybe_define_constant( 'WOOCOMMERCE_CART', true ); + } + $script_data = $this->smart_button->script_data(); wp_send_json_success( array( 'url_params' => $script_data['url_params'], + 'button' => $script_data['button'], + 'messages' => $script_data['messages'], 'amount' => WC()->cart->get_total( 'raw' ), ) ); diff --git a/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php b/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php index 189099485..95979d1b2 100644 --- a/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/ChangeCartEndpoint.php @@ -11,26 +11,15 @@ namespace WooCommerce\PayPalCommerce\Button\Endpoint; use Exception; use Psr\Log\LoggerInterface; -use WooCommerce\PayPalCommerce\ApiClient\Entity\PurchaseUnit; -use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; -use WooCommerce\PayPalCommerce\Button\Exception\RuntimeException; /** * Class ChangeCartEndpoint */ -class ChangeCartEndpoint implements EndpointInterface { - +class ChangeCartEndpoint extends AbstractCartEndpoint { const ENDPOINT = 'ppc-change-cart'; - /** - * The current cart object. - * - * @var \WC_Cart - */ - private $cart; - /** * The current shipping object. * @@ -38,13 +27,6 @@ class ChangeCartEndpoint implements EndpointInterface { */ private $shipping; - /** - * The request data helper. - * - * @var RequestData - */ - private $request_data; - /** * The PurchaseUnit factory. * @@ -52,20 +34,6 @@ class ChangeCartEndpoint implements EndpointInterface { */ private $purchase_unit_factory; - /** - * The product data store. - * - * @var \WC_Data_Store - */ - private $product_data_store; - - /** - * The logger. - * - * @var LoggerInterface - */ - protected $logger; - /** * ChangeCartEndpoint constructor. * @@ -91,38 +59,8 @@ class ChangeCartEndpoint implements EndpointInterface { $this->purchase_unit_factory = $purchase_unit_factory; $this->product_data_store = $product_data_store; $this->logger = $logger; - } - /** - * The nonce. - * - * @return string - */ - public static function nonce(): string { - return self::ENDPOINT; - } - - /** - * Handles the request. - * - * @return bool - */ - public function handle_request(): bool { - try { - return $this->handle_data(); - } catch ( Exception $error ) { - $this->logger->error( 'Cart updating failed: ' . $error->getMessage() ); - - wp_send_json_error( - array( - 'name' => is_a( $error, PayPalApiException::class ) ? $error->name() : '', - 'message' => $error->getMessage(), - 'code' => $error->getCode(), - 'details' => is_a( $error, PayPalApiException::class ) ? $error->details() : array(), - ) - ); - return false; - } + $this->logger_tag = 'updating'; } /** @@ -131,195 +69,21 @@ class ChangeCartEndpoint implements EndpointInterface { * @return bool * @throws Exception On error. */ - private function handle_data(): bool { - $data = $this->request_data->read_request( $this->nonce() ); - $products = $this->products_from_data( $data ); + protected function handle_data(): bool { + $products = $this->products_from_request(); + if ( ! $products ) { - wp_send_json_error( - array( - 'name' => '', - 'message' => __( - 'Necessary fields not defined. Action aborted.', - 'woocommerce-paypal-payments' - ), - 'code' => 0, - 'details' => array(), - ) - ); return false; } $this->shipping->reset_shipping(); - $this->cart->empty_cart( false ); - $success = true; - foreach ( $products as $product ) { - if ( $product['product']->is_type( 'booking' ) ) { - $success = $success && $this->add_booking_product( - $product['product'], - $product['booking'] - ); - } elseif ( $product['product']->is_type( 'variable' ) ) { - $success = $success && $this->add_variable_product( - $product['product'], - $product['quantity'], - $product['variations'] - ); - } else { - $success = $success && $this->add_product( - $product['product'], - $product['quantity'] - ); - } - } - if ( ! $success ) { - $this->handle_error(); - return $success; - } - wp_send_json_success( $this->generate_purchase_units() ); - return $success; - } - - /** - * Handles errors. - * - * @return bool - */ - private function handle_error(): bool { - - $message = __( - 'Something went wrong. Action aborted', - 'woocommerce-paypal-payments' - ); - $errors = wc_get_notices( 'error' ); - if ( count( $errors ) ) { - $message = array_reduce( - $errors, - static function ( string $add, array $error ): string { - return $add . $error['notice'] . ' '; - }, - '' - ); - wc_clear_notices(); - } - - wp_send_json_error( - array( - 'name' => '', - 'message' => $message, - 'code' => 0, - 'details' => array(), - ) - ); - return true; - } - - /** - * Returns product information from an data array. - * - * @param array $data The data array. - * - * @return array|null - */ - private function products_from_data( array $data ) { - - $products = array(); - - if ( - ! isset( $data['products'] ) - || ! is_array( $data['products'] ) - ) { - return null; - } - foreach ( $data['products'] as $product ) { - if ( ! isset( $product['quantity'] ) || ! isset( $product['id'] ) ) { - return null; - } - - $wc_product = wc_get_product( (int) $product['id'] ); - - if ( ! $wc_product ) { - return null; - } - $products[] = array( - 'product' => $wc_product, - 'quantity' => (int) $product['quantity'], - 'variations' => $product['variations'] ?? null, - 'booking' => $product['booking'] ?? null, - ); - } - return $products; - } - - /** - * Adds a product to the cart. - * - * @param \WC_Product $product The Product. - * @param int $quantity The Quantity. - * - * @return bool - * @throws Exception When product could not be added. - */ - private function add_product( \WC_Product $product, int $quantity ): bool { - return false !== $this->cart->add_to_cart( $product->get_id(), $quantity ); - } - - - /** - * Adds variations to the cart. - * - * @param \WC_Product $product The Product. - * @param int $quantity The Quantity. - * @param array $post_variations The variations. - * - * @return bool - * @throws Exception When product could not be added. - */ - private function add_variable_product( - \WC_Product $product, - int $quantity, - array $post_variations - ): bool { - - $variations = array(); - foreach ( $post_variations as $key => $value ) { - $variations[ $value['name'] ] = $value['value']; - } - - $variation_id = $this->product_data_store->find_matching_product_variation( $product, $variations ); - - // ToDo: Check stock status for variation. - return false !== $this->cart->add_to_cart( - $product->get_id(), - $quantity, - $variation_id, - $variations - ); - } - - /** - * Adds variations to the cart. - * - * @param \WC_Product $product The Product. - * @param array $data Data used by the booking plugin. - * - * @return bool - * @throws Exception When product could not be added. - */ - private function add_booking_product( - \WC_Product $product, - array $data - ): bool { - - if ( ! is_callable( 'wc_bookings_get_posted_data' ) ) { + if ( ! $this->add_products( $products ) ) { return false; } - $cart_item_data = array( - 'booking' => wc_bookings_get_posted_data( $data, $product ), - ); - - return false !== $this->cart->add_to_cart( $product->get_id(), 1, 0, array(), $cart_item_data ); + wp_send_json_success( $this->generate_purchase_units() ); + return true; } /** diff --git a/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php new file mode 100644 index 000000000..5e3796dbb --- /dev/null +++ b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php @@ -0,0 +1,122 @@ +smart_button = $smart_button; + $this->cart = clone $cart; + $this->request_data = $request_data; + $this->product_data_store = $product_data_store; + $this->logger = $logger; + + $this->logger_tag = 'simulation'; + } + + /** + * Handles the request data. + * + * @return bool + * @throws Exception On error. + */ + protected function handle_data(): bool { + $products = $this->products_from_request(); + + if ( ! $products ) { + return false; + } + + // Set WC default cart as the clone. + // Store a reference to the real cart. + $active_cart = WC()->cart; + WC()->cart = $this->cart; + + 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 = $active_cart; + unset( $this->cart ); + + // Process filters. + $pay_later_enabled = true; + $pay_later_messaging_enabled = true; + $button_enabled = true; + + foreach ( $products as $product ) { + $context_data = array( + 'product' => $product['product'], + 'order_total' => $total, + ); + + $pay_later_enabled = $pay_later_enabled && $this->smart_button->is_pay_later_button_enabled_for_location( 'product', $context_data ); + $pay_later_messaging_enabled = $pay_later_messaging_enabled && $this->smart_button->is_pay_later_messaging_enabled_for_location( 'product', $context_data ); + $button_enabled = $button_enabled && ! $this->smart_button->is_button_disabled( 'product', $context_data ); + } + + wp_send_json_success( + array( + 'total' => $total, + 'funding' => array( + 'paylater' => array( + 'enabled' => $pay_later_enabled, + ), + ), + 'button' => array( + 'is_disabled' => ! $button_enabled, + ), + 'messages' => array( + 'is_hidden' => ! $pay_later_messaging_enabled, + ), + ) + ); + return true; + } + +} diff --git a/modules/ppcp-button/yarn.lock b/modules/ppcp-button/yarn.lock index e76c992e1..9d5ebef6e 100644 --- a/modules/ppcp-button/yarn.lock +++ b/modules/ppcp-button/yarn.lock @@ -956,6 +956,13 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@paypal/paypal-js@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@paypal/paypal-js/-/paypal-js-6.0.0.tgz#a5a9556af29e4a0124049bf9a093606f52b8a951" + integrity sha512-FYzjYby9F7tgg4tUxYNseZ6vkeDJcdcjoULsyNhfrWZZjicDpdj5932fZlyUlQXDSR9KlhjXH6H4nPIJ0Lq0Kw== + dependencies: + promise-polyfill "^8.3.0" + "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" @@ -1858,6 +1865,11 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +promise-polyfill@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + punycode@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" diff --git a/modules/ppcp-onboarding/src/OnboardingModule.php b/modules/ppcp-onboarding/src/OnboardingModule.php index b6eaaa1aa..1f94f0fee 100644 --- a/modules/ppcp-onboarding/src/OnboardingModule.php +++ b/modules/ppcp-onboarding/src/OnboardingModule.php @@ -11,7 +11,6 @@ namespace WooCommerce\PayPalCommerce\Onboarding; use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider; use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface; -use WooCommerce\PayPalCommerce\Button\Endpoint\ChangeCartEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Assets\OnboardingAssets; use WooCommerce\PayPalCommerce\Onboarding\Endpoint\LoginSellerEndpoint; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingRenderer; @@ -88,9 +87,9 @@ class OnboardingModule implements ModuleInterface { $endpoint = $c->get( 'onboarding.endpoint.login-seller' ); /** - * The ChangeCartEndpoint. + * The LoginSellerEndpoint. * - * @var ChangeCartEndpoint $endpoint + * @var LoginSellerEndpoint $endpoint */ $endpoint->handle_request(); } diff --git a/modules/ppcp-wc-gateway/resources/js/gateway-settings.js b/modules/ppcp-wc-gateway/resources/js/gateway-settings.js index 01ac034c8..c20432d48 100644 --- a/modules/ppcp-wc-gateway/resources/js/gateway-settings.js +++ b/modules/ppcp-wc-gateway/resources/js/gateway-settings.js @@ -2,9 +2,10 @@ import { loadScript } from "@paypal/paypal-js"; import {debounce} from "./helper/debounce"; import Renderer from '../../../ppcp-button/resources/js/modules/Renderer/Renderer' import MessageRenderer from "../../../ppcp-button/resources/js/modules/Renderer/MessageRenderer"; -import {setVisibleByClass, isVisible} from "../../../ppcp-button/resources/js/modules/Helper/Hiding" +import {setVisibleByClass, isVisible} from "../../../ppcp-button/resources/js/modules/Helper/Hiding"; +import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder"; -;document.addEventListener( +document.addEventListener( 'DOMContentLoaded', () => { function disableAll(nodeList){ @@ -138,6 +139,8 @@ import {setVisibleByClass, isVisible} from "../../../ppcp-button/resources/js/mo function loadPaypalScript(settings, onLoaded = () => {}) { loadScript(JSON.parse(JSON.stringify(settings))) // clone the object to prevent modification .then(paypal => { + widgetBuilder.setPaypal(paypal); + document.dispatchEvent(new CustomEvent('ppcp_paypal_script_loaded')); onLoaded(paypal);