Skip to content

Commit

Permalink
Implement Stripe Express Checkout Elements (ECE) on the Product page (#…
Browse files Browse the repository at this point in the history
…3441)

* add ece feature flag

* update '@stripe/react-stripe-js' to latest

* render ece button on block checkout

* display ece button if feature flag is enabled

* check ece feature flag status from blocks data

* add min height to express checkout container

* create 'WC_Stripe_Express_Checkout_Element' class

* fix callback function name

* register script for shortcode checkout

* move ajax functions to separate class

* move helper functions to a separate class

* include and initialize express checkout classes

* make functions public in the helper class

* fix lint issue

* use correct global variable

* fix php lint issues

* Integrating ECE to shortcode checkout

* Adding extra contraints to show ECE

* Importing additional implementations from WCPay

* Use our own Stripe tracking

* Fix typos in normalizeOrderData

* Add missing client/api methods to handle ECE requests

* Update ESLint config to not require await directly within async functions

* Fix JS linting issues

* Use startExpressCheckoutElement() to init the payment button

* Add getExpressCheckoutAjaxURL util function

* Update ECE API functions to use express checkout data

* Move api const to top and add init function for page specific initialization

* Fix comment

* Adding changelog entry

* Putting display none back

* Multiple changes to the display logic based on WCPay

* Multiple changes to the display logic based on WCPay

* Fix multiple issues + changing the main element ID

* Removing unsupported coalesce operator

* Including missing methods

* Fix lint issues

* Fix lint issues

* Updating ESLint version and requirement

* ECE instantiation options update

* Fix get/update shipping option AJAX requests

* Rename WooPayment related funtion and fix camelcase issue

* Fetch proper requestShipping meta from product data

* Set 'wc-stripe-is-deferred-intent' in data submitted with the checkout when using ECE

* Fix incorrect product prices when store has price includes tax setting

* Implement adding the product to the cart when clicking the ECE from the product page

* Fix tax and shipping values being displayed as NULL

* Don't send shipping line items if the store doesn't have shipping methods

* Initialize/start the Stripe Express Checkout Elements on the product page

* Fix changing quantities and variations, also add support for deposits

* Use correct get shipping nonce key

* Add support for add bookings products to the cart

* When changing booking dates, clear the cart and delete any 'in-cart' bookings created

* Fix whitespace

* Delete duplicate files that have moved to client/express-checkout

* Fix merge conflict errors

* Bring fixes from develop to this branch due to bad handling of conflicts

* Bring fixes from develop to this branch due to bad handling of conflicts

* Add changelog entry

* Add deposits support by hiding itemization when cart contains deposits

* fix line items totals not matching total amount

* send currency when fetching the stripe amount after selecting a variation on product page

* Add changelog entry

---------

Co-authored-by: Mayisha <[email protected]>
Co-authored-by: Mayisha <[email protected]>
Co-authored-by: Wesley Rosa <[email protected]>
Co-authored-by: James Allan <[email protected]>
  • Loading branch information
5 people authored Oct 1, 2024
1 parent 2eb6546 commit e07054e
Show file tree
Hide file tree
Showing 7 changed files with 375 additions and 38 deletions.
1 change: 0 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ module.exports = {
node: true,
},
rules: {
'require-await': 'error',
'react-hooks/exhaustive-deps': 'error',
'react-hooks/rules-of-hooks': 'error',
'react/jsx-curly-brace-presence': [
Expand Down
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Add - Implemented the "Update all subscriptions payment methods" checkbox on My Account → Payment methods for UPE payment methods.
* Add - Add support for the new Stripe Checkout Element on the shortcode checkout page.
* Add - Add support for the new Stripe Checkout Element on the pay for order page.
* Add - Add support for the new Stripe Checkout Element on the product page.
* Dev - Introduces a new class with payment methods constants.
* Dev - Introduces a new class with currency codes constants.
* Dev - Improves the readability of the redirect URL generation code (UPE).
Expand Down
43 changes: 43 additions & 0 deletions client/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,4 +548,47 @@ export default class WCStripeAPI {
...paymentData,
} );
}

/**
* Add product to cart from product page.
*
* @param {Object} productData Product data.
* @return {Promise} Promise for the request to the server.
*/
expressCheckoutAddToCart( productData ) {
return this.request( getExpressCheckoutAjaxURL( 'add_to_cart' ), {
security: getExpressCheckoutData( 'nonce' )?.add_to_cart,
...productData,
} );
}

/**
* Get selected product data from variable product page.
*
* @param {Object} productData Product data.
* @return {Promise} Promise for the request to the server.
*/
expressCheckoutGetSelectedProductData( productData ) {
return this.request(
getExpressCheckoutAjaxURL( 'get_selected_product_data' ),
{
security: getExpressCheckoutData( 'nonce' )
?.get_selected_product_data,
...productData,
}
);
}

/**
* Empty the cart.
*
* @param {number} bookingId Booking ID.
* @return {Promise} Promise for the request to the server.
*/
expressCheckoutEmptyCart( bookingId ) {
return this.request( getExpressCheckoutAjaxURL( 'clear_cart' ), {
security: getExpressCheckoutData( 'nonce' )?.clear_cart,
booking_id: bookingId,
} );
}
}
179 changes: 178 additions & 1 deletion client/entrypoints/express-checkout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jQuery( function ( $ ) {
}

const publishableKey = getExpressCheckoutData( 'stripe' ).publishable_key;
const quantityInputSelector = '.quantity .qty[type=number]';

if ( ! publishableKey ) {
// If no configuration is present, probably this is not the checkout page.
return;
Expand Down Expand Up @@ -285,7 +287,19 @@ jQuery( function ( $ ) {
order,
} );
} else if ( getExpressCheckoutData( 'is_product_page' ) ) {
// Product page specific initialization.
wcStripeECE.startExpressCheckoutElement( {
mode: 'payment',
total: getExpressCheckoutData( 'product' )?.total.amount,
currency: getExpressCheckoutData( 'product' )?.currency,
requestShipping:
getExpressCheckoutData( 'product' )?.requestShipping ??
false,
requestPhone:
getExpressCheckoutData( 'checkout' )
?.needs_payer_phone ?? false,
displayItems: getExpressCheckoutData( 'product' )
.displayItems,
} );
} else {
// Cart and Checkout page specific initialization.
api.expressCheckoutGetCartDetails().then( ( cart ) => {
Expand All @@ -306,6 +320,131 @@ jQuery( function ( $ ) {
wcStripeECE.paymentAborted = false;
},

getAttributes: () => {
const select = $( '.variations_form' ).find( '.variations select' );
const data = {};
let count = 0;
let chosen = 0;

select.each( function () {
const attributeName =
$( this ).data( 'attribute_name' ) ||
$( this ).attr( 'name' );
const value = $( this ).val() || '';

if ( value.length > 0 ) {
chosen++;
}

count++;
data[ attributeName ] = value;
} );

return {
count,
chosenCount: chosen,
data,
};
},

getSelectedProductData: () => {
let productId = $( '.single_add_to_cart_button' ).val();

// Check if product is a variable product.
if ( $( '.single_variation_wrap' ).length ) {
productId = $( '.single_variation_wrap' )
.find( 'input[name="product_id"]' )
.val();
}

// WC Bookings Support.
if ( $( '.wc-bookings-booking-form' ).length ) {
productId = $( '.wc-booking-product-id' ).val();
}

const addons =
$( '#product-addons-total' ).data( 'price_data' ) || [];
const addonValue = addons.reduce(
( sum, addon ) => sum + addon.cost,
0
);

// WC Deposits Support.
const depositObject = {};
if ( $( 'input[name=wc_deposit_option]' ).length ) {
depositObject.wc_deposit_option = $(
'input[name=wc_deposit_option]:checked'
).val();
}
if ( $( 'input[name=wc_deposit_payment_plan]' ).length ) {
depositObject.wc_deposit_payment_plan = $(
'input[name=wc_deposit_payment_plan]:checked'
).val();
}

const data = {
product_id: productId,
qty: $( quantityInputSelector ).val(),
attributes: $( '.variations_form' ).length
? wcStripeECE.getAttributes().data
: [],
addon_value: addonValue,
...depositObject,
};

return api.expressCheckoutGetSelectedProductData( data );
},

/**
* Adds the item to the cart and return cart details.
*
* @return {Promise} Promise for the request to the server.
*/
addToCart: () => {
let productId = $( '.single_add_to_cart_button' ).val();

// Check if product is a variable product.
if ( $( '.single_variation_wrap' ).length ) {
productId = $( '.single_variation_wrap' )
.find( 'input[name="product_id"]' )
.val();
}

if ( $( '.wc-bookings-booking-form' ).length ) {
productId = $( '.wc-booking-product-id' ).val();
}

const data = {
product_id: productId,
qty: $( quantityInputSelector ).val(),
attributes: $( '.variations_form' ).length
? wcStripeECE.getAttributes().data
: [],
};

// Add extension data to the POST body
const formData = $( 'form.cart' ).serializeArray();
$.each( formData, ( i, field ) => {
if ( /^(addon-|wc_)/.test( field.name ) ) {
if ( /\[\]$/.test( field.name ) ) {
const fieldName = field.name.substring(
0,
field.name.length - 2
);
if ( data[ fieldName ] ) {
data[ fieldName ].push( field.value );
} else {
data[ fieldName ] = [ field.value ];
}
} else {
data[ field.name ] = field.value;
}
}
} );

return api.expressCheckoutAddToCart( data );
},

/**
* Complete payment.
*
Expand Down Expand Up @@ -480,4 +619,42 @@ jQuery( function ( $ ) {
};

wcStripeECE.init();

// Handle bookable products on the product page.
let wcBookingFormChanged = false;

$( document.body )
.off( 'wc_booking_form_changed' )
.on( 'wc_booking_form_changed', () => {
wcBookingFormChanged = true;
} );

// Listen for the WC Bookings wc_bookings_calculate_costs event to complete
// and add the bookable product to the cart, using the response to update the
// payment request request params with correct totals.
$( document ).ajaxComplete( function ( event, xhr, settings ) {
if ( wcBookingFormChanged ) {
if (
settings.url === window.booking_form_params.ajax_url &&
settings.data.includes( 'wc_bookings_calculate_costs' ) &&
xhr.responseText.includes( 'SUCCESS' )
) {
wcStripeECE.blockExpressCheckoutButton();
wcBookingFormChanged = false;

return wcStripeECE.addToCart().then( ( response ) => {
getExpressCheckoutData( 'product' ).total = response.total;
getExpressCheckoutData( 'product' ).displayItems =
response.displayItems;

// Empty the cart to avoid having 2 products in the cart when payment request is not used.
api.expressCheckoutEmptyCart( response.bookingId );

wcStripeECE.init();

wcStripeECE.unblockExpressCheckoutButton();
} );
}
}
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public function ajax_add_to_cart() {
WC()->cart->add_to_cart( $product->get_id(), $qty, $variation_id, $attributes );
}

if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation' ], true ) ) {
if ( in_array( $product_type, [ 'simple', 'variation', 'subscription', 'subscription_variation', 'booking' ], true ) ) {
WC()->cart->add_to_cart( $product->get_id(), $qty );
}

Expand All @@ -110,6 +110,14 @@ public function ajax_add_to_cart() {
$data += $this->express_checkout_helper->build_display_items();
$data['result'] = 'success';

if ( 'booking' === $product_type ) {
$booking_id = $this->express_checkout_helper->get_booking_id_from_cart();

if ( ! empty( $booking_id ) ) {
$data['bookingId'] = $booking_id;
}
}

// @phpstan-ignore-next-line (return statement is added)
wp_send_json( $data );
}
Expand All @@ -120,7 +128,17 @@ public function ajax_add_to_cart() {
public function ajax_clear_cart() {
check_ajax_referer( 'wc-stripe-clear-cart', 'security' );

$booking_id = isset( $_POST['booking_id'] ) ? absint( $_POST['booking_id'] ) : null;

WC()->cart->empty_cart();

if ( $booking_id ) {
// When a bookable product is added to the cart, a 'booking' is create with status 'in-cart'.
// This status is used to prevent the booking from being booked by another customer
// and should be removed when the cart is emptied for PRB purposes.
do_action( 'wc-booking-remove-inactive-cart', $booking_id ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
}

exit;
}

Expand Down Expand Up @@ -186,11 +204,14 @@ public function ajax_get_selected_product_data() {
check_ajax_referer( 'wc-stripe-get-selected-product-data', 'security' );

try { // @phpstan-ignore-line (return statement is added)
$product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;
$qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id );
$addon_value = isset( $_POST['addon_value'] ) ? max( floatval( $_POST['addon_value'] ), 0 ) : 0;
$product = wc_get_product( $product_id );
$variation_id = null;
$product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;
$qty = ! isset( $_POST['qty'] ) ? 1 : apply_filters( 'woocommerce_add_to_cart_quantity', absint( $_POST['qty'] ), $product_id );
$addon_value = isset( $_POST['addon_value'] ) ? max( floatval( $_POST['addon_value'] ), 0 ) : 0;
$product = wc_get_product( $product_id );
$variation_id = null;
$currency = get_woocommerce_currency();
$is_deposit = isset( $_POST['wc_deposit_option'] ) ? 'yes' === sanitize_text_field( wp_unslash( $_POST['wc_deposit_option'] ) ) : null;
$deposit_plan_id = isset( $_POST['wc_deposit_payment_plan'] ) ? absint( $_POST['wc_deposit_payment_plan'] ) : 0;

if ( ! is_a( $product, 'WC_Product' ) ) {
/* translators: 1) The product Id */
Expand Down Expand Up @@ -222,27 +243,35 @@ public function ajax_get_selected_product_data() {
throw new Exception( sprintf( __( 'You cannot add that amount of "%1$s"; to the cart because there is not enough stock (%2$s remaining).', 'woocommerce-gateway-stripe' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity(), $product ) ) );
}

$total = $qty * $this->express_checkout_helper->get_product_price( $product ) + $addon_value;
$price = $this->express_checkout_helper->get_product_price( $product, $is_deposit, $deposit_plan_id );
$total = $qty * $price + $addon_value;

$quantity_label = 1 < $qty ? ' (x' . $qty . ')' : '';

$data = [];
$items = [];
$data = [
'currency' => strtolower( $currency ),
'country_code' => substr( get_option( 'woocommerce_default_country' ), 0, 2 ),
'requestShipping' => wc_shipping_enabled() && 0 !== wc_get_shipping_method_count( true ) && $product->needs_shipping(),
];

$items[] = [
'label' => $product->get_name() . $quantity_label,
'amount' => WC_Stripe_Helper::get_stripe_amount( $total ),
];

if ( wc_tax_enabled() ) {
$total_tax = 0;
foreach ( $this->express_checkout_helper->get_taxes_like_cart( $product, $price ) as $tax ) {
$total_tax += $tax;

$items[] = [
'label' => __( 'Tax', 'woocommerce-gateway-stripe' ),
'amount' => 0,
'pending' => true,
'amount' => WC_Stripe_Helper::get_stripe_amount( $tax, $currency ),
'pending' => 0 === $tax,
];
}

if ( wc_shipping_enabled() && $product->needs_shipping() ) {
if ( true === $data['requestShipping'] ) {
$items[] = [
'label' => __( 'Shipping', 'woocommerce-gateway-stripe' ),
'amount' => 0,
Expand All @@ -260,13 +289,9 @@ public function ajax_get_selected_product_data() {
$data['displayItems'] = $items;
$data['total'] = [
'label' => $this->express_checkout_helper->get_total_label(),
'amount' => WC_Stripe_Helper::get_stripe_amount( $total ),
'amount' => WC_Stripe_Helper::get_stripe_amount( $total + $total_tax, $currency ),
];

$data['requestShipping'] = ( wc_shipping_enabled() && $product->needs_shipping() );
$data['currency'] = strtolower( get_woocommerce_currency() );
$data['country_code'] = substr( get_option( 'woocommerce_default_country' ), 0, 2 );

wp_send_json( $data );
} catch ( Exception $e ) {
wp_send_json( [ 'error' => wp_strip_all_tags( $e->getMessage() ) ] );
Expand Down
Loading

0 comments on commit e07054e

Please sign in to comment.