From 3b5a4b7f23d7ecc8cc4894df17b73d9f595616e5 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Wed, 5 Jul 2023 17:10:26 +0100 Subject: [PATCH] Add cart cleanup functionality to single page Bookable products --- modules/ppcp-api-client/src/Entity/Item.php | 43 +++++++++--- .../src/Factory/ItemFactory.php | 7 +- .../SingleProductActionHandler.js | 48 ++++++++++---- .../js/modules/Helper/CartJanitor.js | 65 +++++++++++++++++++ .../resources/js/modules/Helper/FormHelper.js | 17 +++++ 5 files changed, 156 insertions(+), 24 deletions(-) create mode 100644 modules/ppcp-button/resources/js/modules/Helper/CartJanitor.js create mode 100644 modules/ppcp-button/resources/js/modules/Helper/FormHelper.js diff --git a/modules/ppcp-api-client/src/Entity/Item.php b/modules/ppcp-api-client/src/Entity/Item.php index 374c2efcb..0f576f32c 100644 --- a/modules/ppcp-api-client/src/Entity/Item.php +++ b/modules/ppcp-api-client/src/Entity/Item.php @@ -73,6 +73,13 @@ class Item { */ protected $tax_rate; + /** + * The tax rate. + * + * @var string|null + */ + protected $cart_item_key; + /** * Item constructor. * @@ -84,6 +91,7 @@ class Item { * @param string $sku The SKU. * @param string $category The category. * @param float $tax_rate The tax rate. + * @param ?string $cart_item_key The cart key for this item. */ public function __construct( string $name, @@ -93,18 +101,20 @@ class Item { Money $tax = null, string $sku = '', string $category = 'PHYSICAL_GOODS', - float $tax_rate = 0 + float $tax_rate = 0, + string $cart_item_key = null ) { - $this->name = $name; - $this->unit_amount = $unit_amount; - $this->quantity = $quantity; - $this->description = $description; - $this->tax = $tax; - $this->sku = $sku; - $this->category = ( self::DIGITAL_GOODS === $category ) ? self::DIGITAL_GOODS : self::PHYSICAL_GOODS; - $this->category = $category; - $this->tax_rate = $tax_rate; + $this->name = $name; + $this->unit_amount = $unit_amount; + $this->quantity = $quantity; + $this->description = $description; + $this->tax = $tax; + $this->sku = $sku; + $this->category = ( self::DIGITAL_GOODS === $category ) ? self::DIGITAL_GOODS : self::PHYSICAL_GOODS; + $this->category = $category; + $this->tax_rate = $tax_rate; + $this->cart_item_key = $cart_item_key; } /** @@ -179,6 +189,15 @@ class Item { return round( (float) $this->tax_rate, 2 ); } + /** + * Returns the cart key for this item. + * + * @return string + */ + public function cart_item_key():?string { + return $this->cart_item_key; + } + /** * Returns the object as array. * @@ -202,6 +221,10 @@ class Item { $item['tax_rate'] = (string) $this->tax_rate(); } + if ( $this->cart_item_key() ) { + $item['cart_item_key'] = (string) $this->cart_item_key(); + } + return $item; } } diff --git a/modules/ppcp-api-client/src/Factory/ItemFactory.php b/modules/ppcp-api-client/src/Factory/ItemFactory.php index 0c5e9b542..ab3f0a95b 100644 --- a/modules/ppcp-api-client/src/Factory/ItemFactory.php +++ b/modules/ppcp-api-client/src/Factory/ItemFactory.php @@ -44,7 +44,8 @@ class ItemFactory { public function from_wc_cart( \WC_Cart $cart ): array { $items = array_map( function ( array $item ): Item { - $product = $item['data']; + $product = $item['data']; + $cart_item_key = $item['key'] ?? null; /** * The WooCommerce product. @@ -61,7 +62,9 @@ class ItemFactory { $this->prepare_description( $product->get_description() ), null, $product->get_sku(), - ( $product->is_virtual() ) ? Item::DIGITAL_GOODS : Item::PHYSICAL_GOODS + ( $product->is_virtual() ) ? Item::DIGITAL_GOODS : Item::PHYSICAL_GOODS, + 0, + $cart_item_key ); }, $cart->get_cart_contents() diff --git a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js index a7c8ec8ec..6d2e83706 100644 --- a/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js +++ b/modules/ppcp-button/resources/js/modules/ActionHandler/SingleProductActionHandler.js @@ -3,6 +3,8 @@ import BookingProduct from "../Entity/BookingProduct"; import onApprove from '../OnApproveHandler/onApproveForContinue'; import {payerData} from "../Helper/PayerData"; import {PaymentMethods} from "../Helper/CheckoutMethodState"; +import CartJanitor from "../Helper/CartJanitor"; +import FormHelper from "../Helper/FormHelper"; class SingleProductActionHandler { @@ -16,6 +18,7 @@ class SingleProductActionHandler { this.updateCart = updateCart; this.formElement = formElement; this.errorHandler = errorHandler; + this.cartJanitor = null; } subscriptionsConfiguration() { @@ -74,29 +77,36 @@ class SingleProductActionHandler { createOrder: this.createOrder(), onApprove: onApprove(this, this.errorHandler), onError: (error) => { + this.refreshMiniCart(); + + if (this.isBookingProduct() && error.message) { + this.errorHandler.clear(); + this.errorHandler.message(error.message); + return; + } this.errorHandler.genericError(); + }, + onCancel: () => { + // Could be used for every product type, + // but only clean the cart for Booking products for now. + if (this.isBookingProduct()) { + this.cleanCart(); + } else { + this.refreshMiniCart(); + } } } } createOrder() { + this.cartJanitor = null; + let getProducts = (() => { if ( this.isBookingProduct() ) { return () => { - - const getPrefixedFields = (formElement, prefix) => { - let fields = {}; - for(const element of formElement.elements) { - if( element.name.startsWith(prefix) ) { - fields[element.name] = element.value; - } - } - return fields; - } - const id = document.querySelector('[name="add-to-cart"]').value; - return [new BookingProduct(id, 1, getPrefixedFields(this.formElement, "wc_bookings_field"))]; + return [new BookingProduct(id, 1, FormHelper.getPrefixedFields(this.formElement, "wc_bookings_field"))]; } } else if ( this.isGroupedProduct() ) { return () => { @@ -129,6 +139,8 @@ class SingleProductActionHandler { this.errorHandler.clear(); const onResolve = (purchase_units) => { + this.cartJanitor = (new CartJanitor()).addFromPurchaseUnits(purchase_units); + const payer = payerData(); const bnCode = typeof this.config.bn_codes[this.config.context] !== 'undefined' ? this.config.bn_codes[this.config.context] : ''; @@ -193,5 +205,17 @@ class SingleProductActionHandler { return !!this.formElement.querySelector('.wc-booking-product-id'); } + cleanCart() { + this.cartJanitor.removeFromCart().then(() => { + this.refreshMiniCart(); + }).catch(error => { + this.refreshMiniCart(); + }); + } + + refreshMiniCart() { + jQuery(document.body).trigger('wc_fragment_refresh'); + } + } export default SingleProductActionHandler; diff --git a/modules/ppcp-button/resources/js/modules/Helper/CartJanitor.js b/modules/ppcp-button/resources/js/modules/Helper/CartJanitor.js new file mode 100644 index 000000000..aa4c1d839 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/CartJanitor.js @@ -0,0 +1,65 @@ +class CartJanitor { + + constructor(cartItemKeys = []) + { + this.endpoint = wc_cart_fragments_params.wc_ajax_url.toString().replace('%%endpoint%%', 'remove_from_cart'); + this.cartItemKeys = cartItemKeys; + } + + addFromPurchaseUnits(purchaseUnits) { + for (const purchaseUnit of purchaseUnits || []) { + for (const item of purchaseUnit.items || []) { + if (!item.cart_item_key) { + continue; + } + this.cartItemKeys.push(item.cart_item_key); + } + } + + return this; + } + + removeFromCart() + { + return new Promise((resolve, reject) => { + if (!this.cartItemKeys || !this.cartItemKeys.length) { + resolve(); + return; + } + + const numRequests = this.cartItemKeys.length; + let numResponses = 0; + + const tryToResolve = () => { + numResponses++; + if (numResponses >= numRequests) { + resolve(); + } + } + + for (const cartItemKey of this.cartItemKeys) { + const params = new URLSearchParams(); + params.append('cart_item_key', cartItemKey); + + if (!cartItemKey) { + tryToResolve(); + continue; + } + + fetch(this.endpoint, { + method: 'POST', + credentials: 'same-origin', + body: params + }).then(function (res) { + return res.json(); + }).then(() => { + tryToResolve(); + }).catch(() => { + tryToResolve(); + }); + } + }); + } +} + +export default CartJanitor; diff --git a/modules/ppcp-button/resources/js/modules/Helper/FormHelper.js b/modules/ppcp-button/resources/js/modules/Helper/FormHelper.js new file mode 100644 index 000000000..ff796e3eb --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/FormHelper.js @@ -0,0 +1,17 @@ + +/** + * Common Form utility methods + */ +export default class FormHelper { + + static getPrefixedFields(formElement, prefix) { + let fields = {}; + for(const element of formElement.elements) { + if( element.name.startsWith(prefix) ) { + fields[element.name] = element.value; + } + } + return fields; + } + +}