Merge pull request #1478 from woocommerce/PCP-895-buttons-not-working-on-single-product-page-for-woo-commerce-bookings-product

Buttons not working on single product page for WooCommerce Bookings product (895)
This commit is contained in:
Emili Castells 2023-07-18 15:36:00 +02:00 committed by GitHub
commit 9d906a1f4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 374 additions and 95 deletions

View file

@ -73,6 +73,13 @@ class Item {
*/ */
protected $tax_rate; protected $tax_rate;
/**
* The cart item key.
*
* @var string|null
*/
protected $cart_item_key;
/** /**
* Item constructor. * Item constructor.
* *
@ -84,6 +91,7 @@ class Item {
* @param string $sku The SKU. * @param string $sku The SKU.
* @param string $category The category. * @param string $category The category.
* @param float $tax_rate The tax rate. * @param float $tax_rate The tax rate.
* @param ?string $cart_item_key The cart key for this item.
*/ */
public function __construct( public function __construct(
string $name, string $name,
@ -93,18 +101,20 @@ class Item {
Money $tax = null, Money $tax = null,
string $sku = '', string $sku = '',
string $category = 'PHYSICAL_GOODS', string $category = 'PHYSICAL_GOODS',
float $tax_rate = 0 float $tax_rate = 0,
string $cart_item_key = null
) { ) {
$this->name = $name; $this->name = $name;
$this->unit_amount = $unit_amount; $this->unit_amount = $unit_amount;
$this->quantity = $quantity; $this->quantity = $quantity;
$this->description = $description; $this->description = $description;
$this->tax = $tax; $this->tax = $tax;
$this->sku = $sku; $this->sku = $sku;
$this->category = ( self::DIGITAL_GOODS === $category ) ? self::DIGITAL_GOODS : self::PHYSICAL_GOODS; $this->category = ( self::DIGITAL_GOODS === $category ) ? self::DIGITAL_GOODS : self::PHYSICAL_GOODS;
$this->category = $category; $this->category = $category;
$this->tax_rate = $tax_rate; $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 ); return round( (float) $this->tax_rate, 2 );
} }
/**
* Returns the cart key for this item.
*
* @return string|null
*/
public function cart_item_key():?string {
return $this->cart_item_key;
}
/** /**
* Returns the object as array. * Returns the object as array.
* *
@ -202,6 +221,10 @@ class Item {
$item['tax_rate'] = (string) $this->tax_rate(); $item['tax_rate'] = (string) $this->tax_rate();
} }
if ( $this->cart_item_key() ) {
$item['cart_item_key'] = (string) $this->cart_item_key();
}
return $item; return $item;
} }
} }

View file

@ -44,7 +44,8 @@ class ItemFactory {
public function from_wc_cart( \WC_Cart $cart ): array { public function from_wc_cart( \WC_Cart $cart ): array {
$items = array_map( $items = array_map(
function ( array $item ): Item { function ( array $item ): Item {
$product = $item['data']; $product = $item['data'];
$cart_item_key = $item['key'] ?? null;
/** /**
* The WooCommerce product. * The WooCommerce product.
@ -61,7 +62,9 @@ class ItemFactory {
$this->prepare_description( $product->get_description() ), $this->prepare_description( $product->get_description() ),
null, null,
$product->get_sku(), $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() $cart->get_cart_contents()

View file

@ -1,7 +1,10 @@
import Product from '../Entity/Product'; import Product from '../Entity/Product';
import BookingProduct from "../Entity/BookingProduct";
import onApprove from '../OnApproveHandler/onApproveForContinue'; import onApprove from '../OnApproveHandler/onApproveForContinue';
import {payerData} from "../Helper/PayerData"; import {payerData} from "../Helper/PayerData";
import {PaymentMethods} from "../Helper/CheckoutMethodState"; import {PaymentMethods} from "../Helper/CheckoutMethodState";
import CartHelper from "../Helper/CartHelper";
import FormHelper from "../Helper/FormHelper";
class SingleProductActionHandler { class SingleProductActionHandler {
@ -15,6 +18,7 @@ class SingleProductActionHandler {
this.updateCart = updateCart; this.updateCart = updateCart;
this.formElement = formElement; this.formElement = formElement;
this.errorHandler = errorHandler; this.errorHandler = errorHandler;
this.cartHelper = null;
} }
subscriptionsConfiguration() { subscriptionsConfiguration() {
@ -73,43 +77,70 @@ class SingleProductActionHandler {
createOrder: this.createOrder(), createOrder: this.createOrder(),
onApprove: onApprove(this, this.errorHandler), onApprove: onApprove(this, this.errorHandler),
onError: (error) => { onError: (error) => {
this.refreshMiniCart();
if (this.isBookingProduct() && error.message) {
this.errorHandler.clear();
this.errorHandler.message(error.message);
return;
}
this.errorHandler.genericError(); 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() createOrder()
{ {
var getProducts = null; this.cartHelper = null;
if (! this.isGroupedProduct() ) {
getProducts = () => { let getProducts = (() => {
const id = document.querySelector('[name="add-to-cart"]').value; if ( this.isBookingProduct() ) {
const qty = document.querySelector('[name="quantity"]').value; return () => {
const variations = this.variations(); const id = document.querySelector('[name="add-to-cart"]').value;
return [new Product(id, qty, variations)]; 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)];
}
} }
} else { })();
getProducts = () => {
const products = []; return (data, actions) => {
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;
}
}
const createOrder = (data, actions) => {
this.errorHandler.clear(); this.errorHandler.clear();
const onResolve = (purchase_units) => { const onResolve = (purchase_units) => {
this.cartHelper = (new CartHelper()).addFromPurchaseUnits(purchase_units);
const payer = payerData(); const payer = payerData();
const bnCode = typeof this.config.bn_codes[this.config.context] !== 'undefined' ? const bnCode = typeof this.config.bn_codes[this.config.context] !== 'undefined' ?
this.config.bn_codes[this.config.context] : ''; this.config.bn_codes[this.config.context] : '';
@ -139,19 +170,16 @@ class SingleProductActionHandler {
}); });
}; };
const promise = this.updateCart.update(onResolve, getProducts()); return this.updateCart.update(onResolve, getProducts());
return promise;
}; };
return createOrder;
} }
variations() variations()
{ {
if (! this.hasVariations()) { if (! this.hasVariations()) {
return null; return null;
} }
const attributes = [...this.formElement.querySelectorAll("[name^='attribute_']")].map( return [...this.formElement.querySelectorAll("[name^='attribute_']")].map(
(element) => { (element) => {
return { return {
value:element.value, value:element.value,
@ -159,7 +187,6 @@ class SingleProductActionHandler {
} }
} }
); );
return attributes;
} }
hasVariations() hasVariations()
@ -171,5 +198,24 @@ class SingleProductActionHandler {
{ {
return this.formElement.classList.contains('grouped_form'); return this.formElement.classList.contains('grouped_form');
} }
isBookingProduct()
{
// detection for "woocommerce-bookings" plugin
return !!this.formElement.querySelector('.wc-booking-product-id');
}
cleanCart() {
this.cartHelper.removeFromCart().then(() => {
this.refreshMiniCart();
}).catch(error => {
this.refreshMiniCart();
});
}
refreshMiniCart() {
jQuery(document.body).trigger('wc_fragment_refresh');
}
} }
export default SingleProductActionHandler; export default SingleProductActionHandler;

View file

@ -0,0 +1,18 @@
import Product from "./Product";
class BookingProduct extends Product {
constructor(id, quantity, booking) {
super(id, quantity, null);
this.booking = booking;
}
data() {
return {
...super.data(),
booking: this.booking
}
}
}
export default BookingProduct;

View file

@ -0,0 +1,65 @@
class CartHelper {
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 CartHelper;

View file

@ -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;
}
}

View file

@ -153,13 +153,23 @@ class ChangeCartEndpoint implements EndpointInterface {
$this->cart->empty_cart( false ); $this->cart->empty_cart( false );
$success = true; $success = true;
foreach ( $products as $product ) { foreach ( $products as $product ) {
$success = $success && ( ! $product['product']->is_type( 'variable' ) ) ? if ( $product['product']->is_type( 'booking' ) ) {
$this->add_product( $product['product'], $product['quantity'] ) $success = $success && $this->add_booking_product(
: $this->add_variable_product( $product['product'],
$product['booking']
);
} elseif ( $product['product']->is_type( 'variable' ) ) {
$success = $success && $this->add_variable_product(
$product['product'], $product['product'],
$product['quantity'], $product['quantity'],
$product['variations'] $product['variations']
); );
} else {
$success = $success && $this->add_product(
$product['product'],
$product['quantity']
);
}
} }
if ( ! $success ) { if ( ! $success ) {
$this->handle_error(); $this->handle_error();
@ -234,7 +244,8 @@ class ChangeCartEndpoint implements EndpointInterface {
$products[] = array( $products[] = array(
'product' => $wc_product, 'product' => $wc_product,
'quantity' => (int) $product['quantity'], 'quantity' => (int) $product['quantity'],
'variations' => isset( $product['variations'] ) ? $product['variations'] : null, 'variations' => $product['variations'] ?? null,
'booking' => $product['booking'] ?? null,
); );
} }
return $products; return $products;
@ -286,6 +297,31 @@ class ChangeCartEndpoint implements EndpointInterface {
); );
} }
/**
* 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' ) ) {
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 );
}
/** /**
* Based on the cart contents, the purchase units are created. * Based on the cart contents, the purchase units are created.
* *

View file

@ -26,13 +26,8 @@ class ChangeCartEndpointTest extends TestCase
->once() ->once()
->with($singleProductArray['id']) ->with($singleProductArray['id'])
->andReturn($products[$productKey]); ->andReturn($products[$productKey]);
if (! $singleProductArray['__test_data_is_variation']) {
$cart if ($singleProductArray['__test_data_is_variation'] ?? false) {
->expects('add_to_cart')
->with($singleProductArray['id'], $singleProductArray['quantity'])
->andReturnTrue();
}
if ($singleProductArray['__test_data_is_variation']) {
$dataStore $dataStore
->expects('find_matching_product_variation') ->expects('find_matching_product_variation')
->with($products[$productKey], $singleProductArray['__test_data_variation_map']) ->with($products[$productKey], $singleProductArray['__test_data_variation_map'])
@ -47,7 +42,34 @@ class ChangeCartEndpointTest extends TestCase
) )
->andReturnTrue(); ->andReturnTrue();
} }
} elseif ($singleProductArray['__test_data_is_booking'] ?? false) {
$processedBooking = array();
foreach ($singleProductArray['booking'] as $key => $value) {
$processedBooking['_processed_' . $key] = $value;
}
expect('wc_bookings_get_posted_data')
->with($singleProductArray['booking'])
->andReturn($processedBooking);
$cart
->expects('add_to_cart')
->with(
$singleProductArray['id'],
$singleProductArray['quantity'],
0,
array(),
array('booking' => $processedBooking)
)
->andReturnTrue();
}
else {
$cart
->expects('add_to_cart')
->with($singleProductArray['id'], $singleProductArray['quantity'])
->andReturnTrue();
}
}
$cart $cart
->expects('empty_cart') ->expects('empty_cart')
->with(false); ->with(false);
@ -88,6 +110,10 @@ class ChangeCartEndpointTest extends TestCase
$defaultProduct $defaultProduct
->shouldReceive('get_id') ->shouldReceive('get_id')
->andReturn(1); ->andReturn(1);
$defaultProduct
->shouldReceive('is_type')
->with('booking')
->andReturn(false);
$defaultProduct $defaultProduct
->shouldReceive('is_type') ->shouldReceive('is_type')
->with('variable') ->with('variable')
@ -97,19 +123,42 @@ class ChangeCartEndpointTest extends TestCase
$variationProduct $variationProduct
->shouldReceive('get_id') ->shouldReceive('get_id')
->andReturn(2); ->andReturn(2);
$variationProduct
->shouldReceive('is_type')
->with('booking')
->andReturn(false);
$variationProduct $variationProduct
->shouldReceive('is_type') ->shouldReceive('is_type')
->with('variable') ->with('variable')
->andReturn(true); ->andReturn(true);
$testData = [ $bookingData = [
'_duration' => 2,
'_start_day' => 12,
'_start_month' => 6,
'_start_year' => 2023,
];
$bookingProduct = Mockery::mock(\WC_Product::class);
$bookingProduct
->shouldReceive('get_id')
->andReturn(3);
$bookingProduct
->shouldReceive('is_type')
->with('booking')
->andReturn(true);
$bookingProduct
->shouldReceive('is_type')
->with('variable')
->andReturn(false);
$testData = [
'default' => [ 'default' => [
[ [
'products' => [ 'products' => [
[ [
'quantity' => 2, 'quantity' => 2,
'id' => 1, 'id' => 1,
'__test_data_is_variation' => false,
], ],
] ]
], ],
@ -121,43 +170,65 @@ class ChangeCartEndpointTest extends TestCase
] ]
], ],
'variation' => [ 'variation' => [
[ [
'products' => [ 'products' => [
[ [
'quantity' => 2, 'quantity' => 2,
'id' => 1, 'id' => 1,
'__test_data_is_variation' => false, ],
], [
[ 'quantity' => 2,
'quantity' => 2, 'id' => 2,
'id' => 2, 'variations' => [
'variations' => [ [
[ 'name' => 'variation-1',
'name' => 'variation-1', 'value' => 'abc',
'value' => 'abc', ],
], [
[ 'name' => 'variation-2',
'name' => 'variation-2', 'value' => 'def',
'value' => 'def', ],
], ],
], '__test_data_is_variation' => true,
'__test_data_is_variation' => true, '__test_data_variation_id' => 123,
'__test_data_variation_id' => 123, '__test_data_variation_map' => [
'__test_data_variation_map' => [ 'variation-1' => 'abc',
'variation-1' => 'abc', 'variation-2' => 'def',
'variation-2' => 'def', ]
] ],
], ]
] ],
], [
[ $defaultProduct,
$defaultProduct, $variationProduct,
$variationProduct, ],
], [
[ [1, 2]
[1, 2] ]
] ],
] 'booking' => [
[
'products' => [
[
'quantity' => 2,
'id' => 1,
],
[
'quantity' => 1,
'id' => 3,
'booking' => $bookingData,
'__test_data_is_booking' => true,
],
]
],
[
$defaultProduct,
$bookingProduct,
],
[
[1, 3]
]
],
]; ];
return $testData; return $testData;