From 4e50d1d6baffc76a872fb844be67e7b6ac70ea0d Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 20 Jul 2023 08:02:15 +0100 Subject: [PATCH] * Add paypal script reloading * Add button reloading in cart and product page * Add checking filters asynchronously --- modules/ppcp-button/package.json | 5 +- modules/ppcp-button/resources/js/button.js | 3 - .../SingleProductActionHandler.js | 63 ++-- .../modules/ContextBootstrap/CartBootstap.js | 16 +- .../ContextBootstrap/SingleProductBootstap.js | 60 +++- .../js/modules/Helper/SimulateCart.js | 48 +++ .../resources/js/modules/Helper/Utils.js | 41 +++ .../js/modules/Renderer/MessageRenderer.js | 2 +- .../resources/js/modules/Renderer/Renderer.js | 70 +++- modules/ppcp-button/services.php | 3 +- .../ppcp-button/src/Assets/SmartButton.php | 25 +- .../src/Endpoint/AbstractCartEndpoint.php | 315 ++++++++++++++++++ .../src/Endpoint/CartScriptParamsEndpoint.php | 5 + .../src/Endpoint/ChangeCartEndpoint.php | 252 +------------- .../src/Endpoint/SimulateCartEndpoint.php | 235 ++----------- modules/ppcp-button/yarn.lock | 12 + 16 files changed, 626 insertions(+), 529 deletions(-) create mode 100644 modules/ppcp-button/resources/js/modules/Helper/SimulateCart.js create mode 100644 modules/ppcp-button/resources/js/modules/Helper/Utils.js create mode 100644 modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php 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..1a4219c48 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -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..1e5f54ea4 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js @@ -98,43 +98,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 +165,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..e3f65e8c5 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CartBootstap.js @@ -40,11 +40,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', this.gateway); + } + + // handle button status + if ( result.data.button ) { + this.gateway.button = result.data.button; + } + + this.handleButtonStatus(); if (this.lastAmount !== result.data.amount) { this.lastAmount = result.data.amount; diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/SingleProductBootstap.js index 231460642..e5ddfb245 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} from "../Helper/Utils"; class SingleProductBootstap { constructor(gateway, renderer, messages, errorHandler) { @@ -38,10 +40,60 @@ class SingleProductBootstap { this.handleButtonStatus(); } - handleButtonStatus() { + handleButtonStatus(simulateCart = true) { BootstrapHelper.handleButtonStatus(this, { formSelector: this.formSelector }); + + 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()); + + //------ + } } init() { @@ -53,12 +105,6 @@ class SingleProductBootstap { form.addEventListener('change', () => { 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 }); 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..565b64863 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/Utils.js @@ -0,0 +1,41 @@ +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); +}; + +const Utils = { + toCamelCase, + keysToCamelCase, + strAddWord, + strRemoveWord +}; + +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..d7fa55f11 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/MessageRenderer.js @@ -44,7 +44,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..c5e9f0525 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/Renderer.js @@ -1,4 +1,6 @@ import merge from "deepmerge"; +import {loadScript} from "@paypal/paypal-js"; +import {keysToCamelCase} from "../Helper/Utils"; class Renderer { constructor(creditCardRenderer, defaultSettings, onSmartButtonClick, onSmartButtonsInit) { @@ -10,6 +12,8 @@ class Renderer { this.buttonsOptions = {}; this.onButtonsInitListeners = {}; + this.activeButtons = {}; + this.renderedSources = new Set(); } @@ -68,32 +72,61 @@ 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) ) { return; } + console.log('rendering', wrapper); + if (fundingSource) { 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); + const buildButtons = (paypal) => { + const btn = paypal.Buttons(buttonsOptions()); + + this.activeButtons[wrapper] = btn; + + if (!btn.isEligible()) { + return; + } + + btn.render(wrapper); + } + + jQuery(wrapper).off('ppcp-reload-buttons'); + jQuery(wrapper).on('ppcp-reload-buttons', (event, settingsOverride = {}) => { + const settings = merge(this.defaultSettings, settingsOverride); + const scriptOptions = keysToCamelCase(settings.url_params); + + // if (this.activeButtons[wrapper]) { + // this.activeButtons[wrapper].close(); + // } + + loadScript(scriptOptions).then((paypal) => { + buildButtons(paypal); + }); + }); this.renderedSources.add(wrapper + fundingSource ?? ''); + + if (typeof paypal !== 'undefined' && typeof paypal.Buttons !== 'undefined') { + buildButtons(paypal); + } } isAlreadyRendered(wrapper, fundingSource, hasEnabledSeparateGateways) { @@ -101,9 +134,10 @@ class Renderer { // 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(); - } + + // if (!hasEnabledSeparateGateways) { + // return document.querySelector(wrapper).hasChildNodes(); + // } return this.renderedSources.has(wrapper + fundingSource ?? ''); } diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 97f2a94cb..88ee20dd0 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -129,11 +129,12 @@ return array( 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( $cart, $request_data, $data_store, $logger ); + return new SimulateCartEndpoint( $smart_button, $cart, $request_data, $data_store, $logger ); }, 'button.endpoint.change-cart' => static function ( ContainerInterface $container ): ChangeCartEndpoint { if ( ! \WC()->cart ) { diff --git a/modules/ppcp-button/src/Assets/SmartButton.php b/modules/ppcp-button/src/Assets/SmartButton.php index 4c47d1502..4aae9f12e 100644 --- a/modules/ppcp-button/src/Assets/SmartButton.php +++ b/modules/ppcp-button/src/Assets/SmartButton.php @@ -1384,10 +1384,10 @@ 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. * @return bool */ - protected function is_button_disabled( string $context = null ): bool { + public function is_button_disabled( string $context = null, float $price_total = null ): bool { if ( null === $context ) { $context = $this->context(); } @@ -1415,7 +1415,8 @@ class SmartButton implements SmartButtonInterface { $is_disabled = apply_filters( 'woocommerce_paypal_payments_buttons_disabled', null, - $context + $context, + null === $price_total ? $this->get_cart_price_total() : $price_total ); if ( $is_disabled !== null ) { @@ -1429,9 +1430,10 @@ 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 float|null $price_total The price total to be considered. * @return bool */ - protected function is_pay_later_filter_enabled_for_location( string $location ): bool { + private function is_pay_later_filter_enabled_for_location( string $location, float $price_total = null ): bool { if ( 'product' === $location ) { $product = wc_get_product(); @@ -1446,8 +1448,7 @@ class SmartButton implements SmartButtonInterface { $is_disabled = apply_filters( 'woocommerce_paypal_payments_product_buttons_paylater_disabled', null, - $product, - $product->get_price( 'numeric' ) + $product ); if ( $is_disabled !== null ) { @@ -1462,7 +1463,7 @@ class SmartButton implements SmartButtonInterface { 'woocommerce_paypal_payments_buttons_paylater_disabled', null, $location, - $this->get_cart_price_total() + null === $price_total ? $this->get_cart_price_total() : $price_total ); if ( $is_disabled !== null ) { @@ -1476,10 +1477,11 @@ class SmartButton implements SmartButtonInterface { * Check whether Pay Later button is enabled for a given 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. */ - private function is_pay_later_button_enabled_for_location( string $location ): bool { - return $this->is_pay_later_filter_enabled_for_location( $location ) + public function is_pay_later_button_enabled_for_location( string $location, float $price_total = null ): bool { + return $this->is_pay_later_filter_enabled_for_location( $location, $price_total ) && $this->settings_status->is_pay_later_button_enabled_for_location( $location ); } @@ -1488,10 +1490,11 @@ class SmartButton implements SmartButtonInterface { * Check whether Pay Later message is enabled for a given location. * * @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. */ - private function is_pay_later_messaging_enabled_for_location( string $location ): bool { - return $this->is_pay_later_filter_enabled_for_location( $location ) + public function is_pay_later_messaging_enabled_for_location( string $location, float $price_total = null ): bool { + return $this->is_pay_later_filter_enabled_for_location( $location, $price_total ) && $this->settings_status->is_pay_later_messaging_enabled_for_location( $location ); } diff --git a/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php b/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php new file mode 100644 index 000000000..9c518938e --- /dev/null +++ b/modules/ppcp-button/src/Endpoint/AbstractCartEndpoint.php @@ -0,0 +1,315 @@ +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 + */ + 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(), + ) + ); + } + + /** + * @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; + } + + /** + * @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..d8c1a3522 100644 --- a/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/CartScriptParamsEndpoint.php @@ -65,11 +65,16 @@ 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'], '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..146af5ca4 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,19 @@ 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 ); - if ( ! $products ) { - wp_send_json_error( - array( - 'name' => '', - 'message' => __( - 'Necessary fields not defined. Action aborted.', - 'woocommerce-paypal-payments' - ), - 'code' => 0, - 'details' => array(), - ) - ); + protected function handle_data(): bool { + if ( ! $products = $this->products_from_request() ) { 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 index 561207c04..8e09a25b2 100644 --- a/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php @@ -11,95 +11,45 @@ namespace WooCommerce\PayPalCommerce\Button\Endpoint; use Exception; use Psr\Log\LoggerInterface; -use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; -use WooCommerce\PayPalCommerce\ApiClient\Factory\PurchaseUnitFactory; +use WooCommerce\PayPalCommerce\Button\Assets\SmartButton; /** * Class SimulateCartEndpoint */ -class SimulateCartEndpoint implements EndpointInterface { +class SimulateCartEndpoint extends AbstractCartEndpoint { const ENDPOINT = 'ppc-simulate-cart'; /** - * The cart object. + * The SmartButton. * - * @var \WC_Cart + * @var SmartButton */ - private $cart; - - /** - * The request data helper. - * - * @var RequestData - */ - private $request_data; - - /** - * The product data store. - * - * @var \WC_Data_Store - */ - private $product_data_store; - - /** - * The logger. - * - * @var LoggerInterface - */ - protected $logger; + private $smart_button; /** * ChangeCartEndpoint constructor. * + * @param SmartButton $smart_button The SmartButton. * @param \WC_Cart $cart The current WC cart object. * @param RequestData $request_data The request data helper. * @param \WC_Data_Store $product_data_store The data store for products. * @param LoggerInterface $logger The logger. */ public function __construct( + SmartButton $smart_button, \WC_Cart $cart, RequestData $request_data, \WC_Data_Store $product_data_store, LoggerInterface $logger ) { - + $this->smart_button = $smart_button; $this->cart = clone $cart; $this->request_data = $request_data; $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 simulation 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 = 'simulation'; } /** @@ -108,167 +58,44 @@ class SimulateCartEndpoint 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 ); - if ( ! $products ) { - wp_send_json_error( - array( - 'name' => '', - 'message' => __( - 'Necessary fields not defined. Action aborted.', - 'woocommerce-paypal-payments' - ), - 'code' => 0, - 'details' => array(), - ) - ); + protected function handle_data(): bool { + if ( ! $products = $this->products_from_request() ) { return false; } - $this->cart->empty_cart( false ); - $success = true; - foreach ( $products as $product ) { - $success = $success && ( ! $product['product']->is_type( 'variable' ) ) ? - $this->add_product( $product['product'], $product['quantity'] ) - : $this->add_variable_product( - $product['product'], - $product['quantity'], - $product['variations'] - ); - } - if ( ! $success ) { - $this->handle_error(); - return $success; + // Set WC default cart as the clone. + // Store a reference to the real cart. + $activeCart = WC()->cart; + WC()->cart = $this->cart; + + if ( ! $this->add_products($products) ) { + return false; } $this->cart->calculate_totals(); + $total = (float) $this->cart->get_total( 'numeric' ); - $total = $this->cart->get_total( 'numeric' ); + $this->remove_cart_items(); + // Restore cart and unset cart clone + WC()->cart = $activeCart; unset( $this->cart ); wp_send_json_success( array( 'total' => $total, - ) - ); - 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(), + 'funding' => [ + 'paylater' => [ + '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' => [ + 'is_disabled' => $this->smart_button->is_button_disabled( 'cart', $total ), + ] ) ); 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' => isset( $product['variations'] ) ? $product['variations'] : 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 - ); - } - } 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"