'
}`
);
+ this.clearData();
this.emailInput.value = this.stripSpaces( this.emailInput.value );
diff --git a/modules/ppcp-axo/resources/js/Components/FormFieldGroup.js b/modules/ppcp-axo/resources/js/Components/FormFieldGroup.js
index b4318a43d..2ea88feee 100644
--- a/modules/ppcp-axo/resources/js/Components/FormFieldGroup.js
+++ b/modules/ppcp-axo/resources/js/Components/FormFieldGroup.js
@@ -1,30 +1,35 @@
class FormFieldGroup {
+ #stored;
+ #data = {};
+ #active = false;
+ #baseSelector;
+ #contentSelector;
+ #fields = {};
+ #template;
+
constructor( config ) {
- this.data = {};
-
- this.baseSelector = config.baseSelector;
- this.contentSelector = config.contentSelector;
- this.fields = config.fields || {};
- this.template = config.template;
-
- this.active = false;
+ this.#baseSelector = config.baseSelector;
+ this.#contentSelector = config.contentSelector;
+ this.#fields = config.fields || {};
+ this.#template = config.template;
+ this.#stored = new Map();
}
setData( data ) {
- this.data = data;
+ this.#data = data;
this.refresh();
}
dataValue( fieldKey ) {
- if ( ! fieldKey || ! this.fields[ fieldKey ] ) {
+ if ( ! fieldKey || ! this.#fields[ fieldKey ] ) {
return '';
}
- if ( typeof this.fields[ fieldKey ].valueCallback === 'function' ) {
- return this.fields[ fieldKey ].valueCallback( this.data );
+ if ( typeof this.#fields[ fieldKey ].valueCallback === 'function' ) {
+ return this.#fields[ fieldKey ].valueCallback( this.#data );
}
- const path = this.fields[ fieldKey ].valuePath;
+ const path = this.#fields[ fieldKey ].valuePath;
if ( ! path ) {
return '';
@@ -35,27 +40,84 @@ class FormFieldGroup {
.reduce(
( acc, key ) =>
acc && acc[ key ] !== undefined ? acc[ key ] : undefined,
- this.data
+ this.#data
);
return value ? value : '';
}
+ /**
+ * Changes the value of the input field.
+ *
+ * @param {Element|null} field
+ * @param {string|boolean} value
+ * @return {boolean} True indicates that the previous value was different from the new value.
+ */
+ #setFieldValue( field, value ) {
+ let oldVal;
+
+ const isValidOption = () => {
+ for ( let i = 0; i < field.options.length; i++ ) {
+ if ( field.options[ i ].value === value ) {
+ return true;
+ }
+ }
+ return false;
+ };
+
+ if ( ! field ) {
+ return false;
+ }
+
+ // If an invalid option is provided, do nothing.
+ if ( 'SELECT' === field.tagName && ! isValidOption() ) {
+ return false;
+ }
+
+ if ( 'checkbox' === field.type || 'radio' === field.type ) {
+ value = !! value;
+ oldVal = field.checked;
+ field.checked = value;
+ } else {
+ oldVal = field.value;
+ field.value = value;
+ }
+
+ return oldVal !== value;
+ }
+
+ /**
+ * Activate form group: Render a custom Fastlane UI to replace the WooCommerce form.
+ *
+ * Indicates: Ryan flow.
+ */
activate() {
- this.active = true;
+ this.#active = true;
+ this.storeFormData();
this.refresh();
}
+ /**
+ * Deactivate form group: Remove the custom Fastlane UI - either display the default
+ * WooCommerce checkout form or no form at all (when no email was provided yet).
+ *
+ * Indicates: Gary flow / no email provided / not using Fastlane.
+ */
deactivate() {
- this.active = false;
+ this.#active = false;
+ this.restoreFormData();
this.refresh();
}
toggle() {
- this.active ? this.deactivate() : this.activate();
+ if ( this.#active ) {
+ this.deactivate();
+ } else {
+ this.activate();
+ }
}
refresh() {
- const content = document.querySelector( this.contentSelector );
+ const content = document.querySelector( this.#contentSelector );
if ( ! content ) {
return;
@@ -63,44 +125,145 @@ class FormFieldGroup {
content.innerHTML = '';
- if ( ! this.active ) {
- this.hideField( this.contentSelector );
+ if ( ! this.#active ) {
+ this.hideField( this.#contentSelector );
} else {
- this.showField( this.contentSelector );
+ this.showField( this.#contentSelector );
}
- Object.keys( this.fields ).forEach( ( key ) => {
- const field = this.fields[ key ];
-
- if ( this.active && ! field.showInput ) {
- this.hideField( field.selector );
+ this.loopFields( ( { selector } ) => {
+ if ( this.#active /* && ! field.showInput */ ) {
+ this.hideField( selector );
} else {
- this.showField( field.selector );
+ this.showField( selector );
}
} );
- if ( typeof this.template === 'function' ) {
- content.innerHTML = this.template( {
+ if ( typeof this.#template === 'function' ) {
+ content.innerHTML = this.#template( {
value: ( fieldKey ) => {
return this.dataValue( fieldKey );
},
isEmpty: () => {
let isEmpty = true;
- Object.keys( this.fields ).forEach( ( fieldKey ) => {
+
+ this.loopFields( ( field, fieldKey ) => {
if ( this.dataValue( fieldKey ) ) {
isEmpty = false;
return false;
}
} );
+
return isEmpty;
},
} );
}
}
+ /**
+ * Invoke a callback on every field in the current group.
+ *
+ * @param {(field: object, key: string) => void} callback
+ */
+ loopFields( callback ) {
+ for ( const [ key, field ] of Object.entries( this.#fields ) ) {
+ const { selector, inputName } = field;
+ const inputSelector = `${ selector } [name="${ inputName }"]`;
+
+ const fieldInfo = {
+ inputSelector: inputName ? inputSelector : '',
+ ...field,
+ };
+
+ callback( fieldInfo, key );
+ }
+ }
+
+ /**
+ * Stores the current form data in an internal storage.
+ * This allows the original form to be restored later.
+ */
+ storeFormData() {
+ const storeValue = ( field, name ) => {
+ if ( 'checkbox' === field.type || 'radio' === field.type ) {
+ this.#stored.set( name, field.checked );
+ this.#setFieldValue( field, this.dataValue( name ) );
+ } else {
+ this.#stored.set( name, field.value );
+ this.#setFieldValue( field, '' );
+ }
+ };
+
+ this.loopFields( ( { inputSelector }, fieldKey ) => {
+ if ( inputSelector && ! this.#stored.has( fieldKey ) ) {
+ const elInput = document.querySelector( inputSelector );
+
+ if ( elInput ) {
+ storeValue( elInput, fieldKey );
+ }
+ }
+ } );
+ }
+
+ /**
+ * Restores the form data to its initial state before the form group was activated.
+ * This function iterates through the stored form fields and resets their values or states.
+ */
+ restoreFormData() {
+ let formHasChanged = false;
+
+ // Reset form fields to their initial state.
+ this.loopFields( ( { inputSelector }, fieldKey ) => {
+ if ( ! this.#stored.has( fieldKey ) ) {
+ return;
+ }
+
+ const elInput = inputSelector
+ ? document.querySelector( inputSelector )
+ : null;
+ const oldValue = this.#stored.get( fieldKey );
+ this.#stored.delete( fieldKey );
+
+ if ( this.#setFieldValue( elInput, oldValue ) ) {
+ formHasChanged = true;
+ }
+ } );
+
+ if ( formHasChanged ) {
+ document.body.dispatchEvent( new Event( 'update_checkout' ) );
+ }
+ }
+
+ /**
+ * Syncs the internal field-data with the hidden checkout form fields.
+ */
+ syncDataToForm() {
+ if ( ! this.#active ) {
+ return;
+ }
+
+ let formHasChanged = false;
+
+ // Push data to the (hidden) checkout form.
+ this.loopFields( ( { inputSelector }, fieldKey ) => {
+ const elInput = inputSelector
+ ? document.querySelector( inputSelector )
+ : null;
+
+ if ( this.#setFieldValue( elInput, this.dataValue( fieldKey ) ) ) {
+ formHasChanged = true;
+ }
+ } );
+
+ // Tell WooCommerce about the changes.
+ if ( formHasChanged ) {
+ document.body.dispatchEvent( new Event( 'update_checkout' ) );
+ }
+ }
+
showField( selector ) {
const field = document.querySelector(
- this.baseSelector + ' ' + selector
+ this.#baseSelector + ' ' + selector
);
if ( field ) {
field.classList.remove( 'ppcp-axo-field-hidden' );
@@ -109,7 +272,7 @@ class FormFieldGroup {
hideField( selector ) {
const field = document.querySelector(
- this.baseSelector + ' ' + selector
+ this.#baseSelector + ' ' + selector
);
if ( field ) {
field.classList.add( 'ppcp-axo-field-hidden' );
@@ -117,7 +280,7 @@ class FormFieldGroup {
}
inputElement( name ) {
- const baseSelector = this.fields[ name ].selector;
+ const baseSelector = this.#fields[ name ].selector;
const select = document.querySelector( baseSelector + ' select' );
if ( select ) {
@@ -138,9 +301,7 @@ class FormFieldGroup {
}
toSubmitData( data ) {
- Object.keys( this.fields ).forEach( ( fieldKey ) => {
- const field = this.fields[ fieldKey ];
-
+ this.loopFields( ( field, fieldKey ) => {
if ( ! field.valuePath || ! field.selector ) {
return true;
}
diff --git a/modules/ppcp-axo/resources/js/Connection/Fastlane.js b/modules/ppcp-axo/resources/js/Connection/Fastlane.js
index d01ae8524..80490b1a4 100644
--- a/modules/ppcp-axo/resources/js/Connection/Fastlane.js
+++ b/modules/ppcp-axo/resources/js/Connection/Fastlane.js
@@ -1,5 +1,6 @@
class Fastlane {
- construct() {
+ constructor( namespace ) {
+ this.namespace = namespace;
this.connection = null;
this.identity = null;
this.profile = null;
@@ -10,7 +11,16 @@ class Fastlane {
connect( config ) {
return new Promise( ( resolve, reject ) => {
- window.paypal
+ if ( ! window[ this.namespace ] ) {
+ reject(
+ new Error(
+ `Namespace ${ this.namespace } not found on window object`
+ )
+ );
+ return;
+ }
+
+ window[ this.namespace ]
.Fastlane( config )
.then( ( result ) => {
this.init( result );
@@ -18,7 +28,7 @@ class Fastlane {
} )
.catch( ( error ) => {
console.error( error );
- reject();
+ reject( error );
} );
} );
}
diff --git a/modules/ppcp-axo/resources/js/Views/BillingView.js b/modules/ppcp-axo/resources/js/Views/BillingView.js
index c9047f417..7f62f7d64 100644
--- a/modules/ppcp-axo/resources/js/Views/BillingView.js
+++ b/modules/ppcp-axo/resources/js/Views/BillingView.js
@@ -45,42 +45,52 @@ class BillingView {
firstName: {
selector: '#billing_first_name_field',
valuePath: null,
+ inputName: 'billing_first_name',
},
lastName: {
selector: '#billing_last_name_field',
valuePath: null,
+ inputName: 'billing_last_name',
},
street1: {
selector: '#billing_address_1_field',
valuePath: 'billing.address.addressLine1',
+ inputName: 'billing_address_1',
},
street2: {
selector: '#billing_address_2_field',
valuePath: null,
+ inputName: 'billing_address_2',
},
postCode: {
selector: '#billing_postcode_field',
valuePath: 'billing.address.postalCode',
+ inputName: 'billing_postcode',
},
city: {
selector: '#billing_city_field',
valuePath: 'billing.address.adminArea2',
+ inputName: 'billing_city',
},
stateCode: {
selector: '#billing_state_field',
valuePath: 'billing.address.adminArea1',
+ inputName: 'billing_state',
},
countryCode: {
selector: '#billing_country_field',
valuePath: 'billing.address.countryCode',
+ inputName: 'billing_country',
},
company: {
selector: '#billing_company_field',
valuePath: null,
+ inputName: 'billing_company',
},
phone: {
selector: '#billing_phone_field',
valuePath: 'billing.phoneNumber',
+ inputName: 'billing_phone',
},
},
} );
diff --git a/modules/ppcp-axo/resources/js/Views/ShippingView.js b/modules/ppcp-axo/resources/js/Views/ShippingView.js
index ba7ffb408..4853659b7 100644
--- a/modules/ppcp-axo/resources/js/Views/ShippingView.js
+++ b/modules/ppcp-axo/resources/js/Views/ShippingView.js
@@ -90,42 +90,54 @@ class ShippingView {
key: 'firstName',
selector: '#shipping_first_name_field',
valuePath: 'shipping.name.firstName',
+ inputName: 'shipping_first_name',
},
lastName: {
selector: '#shipping_last_name_field',
valuePath: 'shipping.name.lastName',
+ inputName: 'shipping_last_name',
},
street1: {
selector: '#shipping_address_1_field',
valuePath: 'shipping.address.addressLine1',
+ inputName: 'shipping_address_1',
},
street2: {
selector: '#shipping_address_2_field',
valuePath: null,
+ inputName: 'shipping_address_2',
},
postCode: {
selector: '#shipping_postcode_field',
valuePath: 'shipping.address.postalCode',
+ inputName: 'shipping_postcode',
},
city: {
selector: '#shipping_city_field',
valuePath: 'shipping.address.adminArea2',
+ inputName: 'shipping_city',
},
stateCode: {
selector: '#shipping_state_field',
valuePath: 'shipping.address.adminArea1',
+ inputName: 'shipping_state',
},
countryCode: {
selector: '#shipping_country_field',
valuePath: 'shipping.address.countryCode',
+ inputName: 'shipping_country',
},
company: {
selector: '#shipping_company_field',
valuePath: null,
+ inputName: 'shipping_company',
},
shipDifferentAddress: {
selector: '#ship-to-different-address',
valuePath: null,
+ inputName: 'ship_to_different_address',
+ // Used by Woo to ensure correct location for taxes & shipping cost.
+ valueCallback: () => true,
},
phone: {
//'selector': '#billing_phone_field', // There is no shipping phone field.
@@ -163,6 +175,7 @@ class ShippingView {
activate() {
this.group.activate();
+ this.group.syncDataToForm();
}
deactivate() {
@@ -175,6 +188,7 @@ class ShippingView {
setData( data ) {
this.group.setData( data );
+ this.group.syncDataToForm();
}
toSubmitData( data ) {
diff --git a/modules/ppcp-axo/resources/js/boot.js b/modules/ppcp-axo/resources/js/boot.js
index 75bc9e636..1effce798 100644
--- a/modules/ppcp-axo/resources/js/boot.js
+++ b/modules/ppcp-axo/resources/js/boot.js
@@ -1,21 +1,27 @@
import AxoManager from './AxoManager';
-import { loadPaypalScript } from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading';
+import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading';
+import { log } from './Helper/Debug';
( function ( { axoConfig, ppcpConfig, jQuery } ) {
+ const namespace = 'ppcpPaypalClassicAxo';
const bootstrap = () => {
- new AxoManager( axoConfig, ppcpConfig );
+ new AxoManager( namespace, axoConfig, ppcpConfig );
};
document.addEventListener( 'DOMContentLoaded', () => {
- if ( ! typeof PayPalCommerceGateway ) {
+ if ( typeof PayPalCommerceGateway === 'undefined' ) {
console.error( 'AXO could not be configured.' );
return;
}
// Load PayPal
- loadPaypalScript( ppcpConfig, () => {
- bootstrap();
- } );
+ loadPayPalScript( namespace, ppcpConfig )
+ .then( () => {
+ bootstrap();
+ } )
+ .catch( ( error ) => {
+ log( `Failed to load PayPal script: ${ error }`, 'error' );
+ } );
} );
} )( {
axoConfig: window.wc_ppcp_axo,
diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php
index b07f8ef53..a0470aeac 100644
--- a/modules/ppcp-axo/src/AxoModule.php
+++ b/modules/ppcp-axo/src/AxoModule.php
@@ -378,7 +378,8 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule {
return ! is_user_logged_in()
&& CartCheckoutDetector::has_classic_checkout()
&& $dcc_configuration->use_fastlane()
- && ! $this->is_excluded_endpoint();
+ && ! $this->is_excluded_endpoint()
+ && is_checkout();
}
/**
diff --git a/modules/ppcp-blocks/resources/js/advanced-card-checkout-block.js b/modules/ppcp-blocks/resources/js/advanced-card-checkout-block.js
index 48df41935..cdce4fd87 100644
--- a/modules/ppcp-blocks/resources/js/advanced-card-checkout-block.js
+++ b/modules/ppcp-blocks/resources/js/advanced-card-checkout-block.js
@@ -7,7 +7,7 @@ registerPaymentMethod( {
name: config.id,
label: ,
content: ,
- edit: ,
+ edit: ,
ariaLabel: config.title,
canMakePayment: () => {
return true;
diff --git a/modules/ppcp-blocks/resources/js/checkout-block.js b/modules/ppcp-blocks/resources/js/checkout-block.js
index a8455b2fd..97590838e 100644
--- a/modules/ppcp-blocks/resources/js/checkout-block.js
+++ b/modules/ppcp-blocks/resources/js/checkout-block.js
@@ -15,21 +15,21 @@ import {
cartHasSubscriptionProducts,
isPayPalSubscription,
} from './Helper/Subscription';
-import { loadPaypalScriptPromise } from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading';
+import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading';
import { PayPalScriptProvider, PayPalButtons } from '@paypal/react-paypal-js';
import { normalizeStyleForFundingSource } from '../../../ppcp-button/resources/js/modules/Helper/Style';
import buttonModuleWatcher from '../../../ppcp-button/resources/js/modules/ButtonModuleWatcher';
import BlockCheckoutMessagesBootstrap from './Bootstrap/BlockCheckoutMessagesBootstrap';
-import { keysToCamelCase } from '../../../ppcp-button/resources/js/modules/Helper/Utils';
-import { handleShippingOptionsChange } from '../../../ppcp-button/resources/js/modules/Helper/ShippingHandler';
+const namespace = 'ppcpBlocksPaypalExpressButtons';
const config = wc.wcSettings.getSetting( 'ppcp-gateway_data' );
window.ppcpFundingSource = config.fundingSource;
let registeredContext = false;
-
let paypalScriptPromise = null;
+const PAYPAL_GATEWAY_ID = 'ppcp-gateway';
+
const PayPalComponent = ( {
onClick,
onClose,
@@ -47,6 +47,7 @@ const PayPalComponent = ( {
const { responseTypes } = emitResponse;
const [ paypalOrder, setPaypalOrder ] = useState( null );
+ const [ continuationFilled, setContinuationFilled ] = useState( false );
const [ gotoContinuationOnError, setGotoContinuationOnError ] =
useState( false );
@@ -55,7 +56,10 @@ const PayPalComponent = ( {
if ( ! paypalScriptLoaded ) {
if ( ! paypalScriptPromise ) {
// for editor, since canMakePayment was not called
- paypalScriptPromise = loadPaypalScriptPromise( config.scriptData );
+ paypalScriptPromise = loadPayPalScript(
+ namespace,
+ config.scriptData
+ );
}
paypalScriptPromise.then( () => setPaypalScriptLoaded( true ) );
}
@@ -64,15 +68,33 @@ const PayPalComponent = ( {
? `${ config.id }-${ fundingSource }`
: config.id;
- useEffect( () => {
- // fill the form if in continuation (for product or mini-cart buttons)
- if (
- ! config.scriptData.continuation ||
- ! config.scriptData.continuation.order ||
- window.ppcpContinuationFilled
- ) {
+ /**
+ * The block cart displays express checkout buttons. Those buttons are handled by the
+ * PAYPAL_GATEWAY_ID method on the server ("PayPal Smart Buttons").
+ *
+ * A possible bug in WooCommerce does not use the correct payment method ID for the express
+ * payment buttons inside the cart, but sends the ID of the _first_ active payment method.
+ *
+ * This function uses an internal WooCommerce dispatcher method to set the correct method ID.
+ */
+ const enforcePaymentMethodForCart = () => {
+ // Do nothing, unless we're handling block cart express payment buttons.
+ if ( 'cart-block' !== config.scriptData.context ) {
return;
}
+
+ // Set the active payment method to PAYPAL_GATEWAY_ID.
+ wp.data
+ .dispatch( 'wc/store/payment' )
+ .__internalSetActivePaymentMethod( PAYPAL_GATEWAY_ID, {} );
+ };
+
+ useEffect( () => {
+ // fill the form if in continuation (for product or mini-cart buttons)
+ if ( continuationFilled || ! config.scriptData.continuation?.order ) {
+ return;
+ }
+
try {
const paypalAddresses = paypalOrderToWcAddresses(
config.scriptData.continuation.order
@@ -81,9 +103,11 @@ const PayPalComponent = ( {
.select( 'wc/store/cart' )
.getCustomerData();
const addresses = mergeWcAddress( wcAddresses, paypalAddresses );
+
wp.data
.dispatch( 'wc/store/cart' )
.setBillingAddress( addresses.billingAddress );
+
if ( shippingData.needsShipping ) {
wp.data
.dispatch( 'wc/store/cart' )
@@ -93,9 +117,10 @@ const PayPalComponent = ( {
// sometimes the PayPal address is missing, skip in this case.
console.log( err );
}
+
// this useEffect should run only once, but adding this in case of some kind of full re-rendering
- window.ppcpContinuationFilled = true;
- }, [] );
+ setContinuationFilled( true );
+ }, [ shippingData, continuationFilled ] );
const createOrder = async ( data, actions ) => {
try {
@@ -232,6 +257,7 @@ const PayPalComponent = ( {
location.href = getCheckoutRedirectUrl();
} else {
setGotoContinuationOnError( true );
+ enforcePaymentMethodForCart();
onSubmit();
}
} catch ( err ) {
@@ -323,6 +349,7 @@ const PayPalComponent = ( {
location.href = getCheckoutRedirectUrl();
} else {
setGotoContinuationOnError( true );
+ enforcePaymentMethodForCart();
onSubmit();
}
} catch ( err ) {
@@ -365,19 +392,19 @@ const PayPalComponent = ( {
};
const shouldHandleShippingInPayPal = () => {
- return shouldskipFinalConfirmation() && config.needShipping
+ return shouldskipFinalConfirmation() && config.needShipping;
};
- const shouldskipFinalConfirmation = () => {
- if ( config.finalReviewEnabled ) {
- return false;
- }
+ const shouldskipFinalConfirmation = () => {
+ if ( config.finalReviewEnabled ) {
+ return false;
+ }
- return (
- window.ppcpFundingSource !== 'venmo' ||
- ! config.scriptData.vaultingEnabled
- );
- };
+ return (
+ window.ppcpFundingSource !== 'venmo' ||
+ ! config.scriptData.vaultingEnabled
+ );
+ };
let handleShippingOptionsChange = null;
let handleShippingAddressChange = null;
@@ -591,7 +618,10 @@ const PayPalComponent = ( {
return null;
}
- const PayPalButton = paypal.Buttons.driver( 'react', { React, ReactDOM } );
+ const PayPalButton = ppcpBlocksPaypalExpressButtons.Buttons.driver(
+ 'react',
+ { React, ReactDOM }
+ );
const getOnShippingOptionsChange = ( fundingSource ) => {
if ( fundingSource === 'venmo' ) {
@@ -611,11 +641,11 @@ const PayPalComponent = ( {
}
return ( data, actions ) => {
- let shippingAddressChange = shouldHandleShippingInPayPal()
+ const shippingAddressChange = shouldHandleShippingInPayPal()
? handleShippingAddressChange( data, actions )
: null;
- return shippingAddressChange;
+ return shippingAddressChange;
};
};
@@ -795,7 +825,8 @@ if ( block_enabled && config.enabled ) {
ariaLabel: config.title,
canMakePayment: async () => {
if ( ! paypalScriptPromise ) {
- paypalScriptPromise = loadPaypalScriptPromise(
+ paypalScriptPromise = loadPayPalScript(
+ namespace,
config.scriptData
);
paypalScriptPromise.then( () => {
@@ -808,7 +839,9 @@ if ( block_enabled && config.enabled ) {
}
await paypalScriptPromise;
- return paypal.Buttons( { fundingSource } ).isEligible();
+ return ppcpBlocksPaypalExpressButtons
+ .Buttons( { fundingSource } )
+ .isEligible();
},
supports: {
features,
diff --git a/modules/ppcp-button/resources/js/modules/Helper/ConfigProcessor.js b/modules/ppcp-button/resources/js/modules/Helper/ConfigProcessor.js
new file mode 100644
index 000000000..b70403a50
--- /dev/null
+++ b/modules/ppcp-button/resources/js/modules/Helper/ConfigProcessor.js
@@ -0,0 +1,34 @@
+import merge from 'deepmerge';
+import { v4 as uuidv4 } from 'uuid';
+import { keysToCamelCase } from './Utils';
+
+const processAxoConfig = ( config ) => {
+ const scriptOptions = {};
+ const sdkClientToken = config?.axo?.sdk_client_token;
+ const uuid = uuidv4().replace( /-/g, '' );
+ if ( sdkClientToken ) {
+ scriptOptions[ 'data-sdk-client-token' ] = sdkClientToken;
+ scriptOptions[ 'data-client-metadata-id' ] = uuid;
+ }
+ return scriptOptions;
+};
+
+const processUserIdToken = ( config, sdkClientToken ) => {
+ const userIdToken = config?.save_payment_methods?.id_token;
+ return userIdToken && ! sdkClientToken
+ ? { 'data-user-id-token': userIdToken }
+ : {};
+};
+
+export const processConfig = ( config ) => {
+ let scriptOptions = keysToCamelCase( config.url_params );
+ if ( config.script_attributes ) {
+ scriptOptions = merge( scriptOptions, config.script_attributes );
+ }
+ const axoOptions = processAxoConfig( config );
+ const userIdTokenOptions = processUserIdToken(
+ config,
+ axoOptions[ 'data-sdk-client-token' ]
+ );
+ return merge.all( [ scriptOptions, axoOptions, userIdTokenOptions ] );
+};
diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayPalScriptLoading.js b/modules/ppcp-button/resources/js/modules/Helper/PayPalScriptLoading.js
new file mode 100644
index 000000000..2fd5feaa3
--- /dev/null
+++ b/modules/ppcp-button/resources/js/modules/Helper/PayPalScriptLoading.js
@@ -0,0 +1,101 @@
+import { loadScript } from '@paypal/paypal-js';
+import dataClientIdAttributeHandler from '../DataClientIdAttributeHandler';
+import widgetBuilder from '../Renderer/WidgetBuilder';
+import { processConfig } from './ConfigProcessor';
+
+const loadedScripts = new Map();
+const scriptPromises = new Map();
+
+const handleDataClientIdAttribute = async ( scriptOptions, config ) => {
+ if (
+ config.data_client_id?.set_attribute &&
+ config.vault_v3_enabled !== '1'
+ ) {
+ return new Promise( ( resolve, reject ) => {
+ dataClientIdAttributeHandler(
+ scriptOptions,
+ config.data_client_id,
+ ( paypal ) => {
+ widgetBuilder.setPaypal( paypal );
+ resolve( paypal );
+ },
+ reject
+ );
+ } );
+ }
+ return null;
+};
+
+export const loadPayPalScript = async ( namespace, config ) => {
+ if ( ! namespace ) {
+ throw new Error( 'Namespace is required' );
+ }
+
+ if ( loadedScripts.has( namespace ) ) {
+ console.log( `Script already loaded for namespace: ${ namespace }` );
+ return loadedScripts.get( namespace );
+ }
+
+ if ( scriptPromises.has( namespace ) ) {
+ console.log(
+ `Script loading in progress for namespace: ${ namespace }`
+ );
+ return scriptPromises.get( namespace );
+ }
+
+ const scriptOptions = {
+ ...processConfig( config ),
+ 'data-namespace': namespace,
+ };
+
+ const dataClientIdResult = await handleDataClientIdAttribute(
+ scriptOptions,
+ config
+ );
+ if ( dataClientIdResult ) {
+ return dataClientIdResult;
+ }
+
+ const scriptPromise = new Promise( ( resolve, reject ) => {
+ loadScript( scriptOptions )
+ .then( ( script ) => {
+ widgetBuilder.setPaypal( script );
+ loadedScripts.set( namespace, script );
+ console.log( `Script loaded for namespace: ${ namespace }` );
+ resolve( script );
+ } )
+ .catch( ( error ) => {
+ console.error(
+ `Failed to load script for namespace: ${ namespace }`,
+ error
+ );
+ reject( error );
+ } )
+ .finally( () => {
+ scriptPromises.delete( namespace );
+ } );
+ } );
+
+ scriptPromises.set( namespace, scriptPromise );
+ return scriptPromise;
+};
+
+export const loadAndRenderPayPalScript = async (
+ namespace,
+ options,
+ renderFunction,
+ renderTarget
+) => {
+ if ( ! namespace ) {
+ throw new Error( 'Namespace is required' );
+ }
+
+ const scriptOptions = {
+ ...options,
+ 'data-namespace': namespace,
+ };
+
+ const script = await loadScript( scriptOptions );
+ widgetBuilder.setPaypal( script );
+ await renderFunction( script, renderTarget );
+};
diff --git a/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php b/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php
index 9d3326010..8341aa29a 100644
--- a/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php
+++ b/modules/ppcp-button/src/Helper/WooCommerceOrderCreator.php
@@ -12,6 +12,7 @@ namespace WooCommerce\PayPalCommerce\Button\Helper;
use Exception;
use RuntimeException;
use WC_Cart;
+use WC_Customer;
use WC_Data_Exception;
use WC_Order;
use WC_Order_Item_Product;
@@ -94,7 +95,7 @@ class WooCommerceOrderCreator {
$this->configure_payment_source( $wc_order );
$this->configure_customer( $wc_order );
$this->configure_line_items( $wc_order, $wc_cart, $payer, $shipping );
- $this->configure_shipping( $wc_order, $payer, $shipping, $wc_cart );
+ $this->configure_addresses( $wc_order, $payer, $shipping, $wc_cart );
$this->configure_coupons( $wc_order, $wc_cart->get_applied_coupons() );
$wc_order->calculate_totals();
@@ -162,7 +163,7 @@ class WooCommerceOrderCreator {
$item->set_total( $subscription_total );
$subscription->add_product( $product );
- $this->configure_shipping( $subscription, $payer, $shipping, $wc_cart );
+ $this->configure_addresses( $subscription, $payer, $shipping, $wc_cart );
$this->configure_payment_source( $subscription );
$this->configure_coupons( $subscription, $wc_cart->get_applied_coupons() );
@@ -190,8 +191,9 @@ class WooCommerceOrderCreator {
* @param WC_Cart $wc_cart The Cart.
* @return void
* @throws WC_Data_Exception|RuntimeException When failing to configure shipping.
+ * @psalm-suppress RedundantConditionGivenDocblockType
*/
- protected function configure_shipping( WC_Order $wc_order, ?Payer $payer, ?Shipping $shipping, WC_Cart $wc_cart ): void {
+ protected function configure_addresses( WC_Order $wc_order, ?Payer $payer, ?Shipping $shipping, WC_Cart $wc_cart ): void {
$shipping_address = null;
$billing_address = null;
$shipping_options = null;
@@ -200,7 +202,16 @@ class WooCommerceOrderCreator {
$address = $payer->address();
$payer_name = $payer->name();
+ $wc_email = null;
+ $wc_customer = WC()->customer;
+ if ( $wc_customer instanceof WC_Customer ) {
+ $wc_email = $wc_customer->get_email();
+ }
+
+ $email = $wc_email ?: $payer->email_address();
+
$billing_address = array(
+ 'email' => $email ?: '',
'first_name' => $payer_name ? $payer_name->given_name() : '',
'last_name' => $payer_name ? $payer_name->surname() : '',
'address_1' => $address ? $address->address_line_1() : '',
diff --git a/modules/ppcp-card-fields/resources/js/Render.js b/modules/ppcp-card-fields/resources/js/Render.js
index 146396288..a0efd78bd 100644
--- a/modules/ppcp-card-fields/resources/js/Render.js
+++ b/modules/ppcp-card-fields/resources/js/Render.js
@@ -8,11 +8,17 @@ function renderField( cardField, inputField ) {
// Insert the PayPal card field after the original input field.
const styles = cardFieldStyles( inputField );
- cardField( { style: { input: styles } } ).render( inputField.parentNode );
+ const fieldOptions = {style: { input: styles },};
- // Hide the original input field.
- hide( inputField, true );
- inputField.hidden = true;
+ if ( inputField.getAttribute( 'placeholder' ) ) {
+ fieldOptions.placeholder = inputField.getAttribute( 'placeholder' );
+ }
+
+ cardField( fieldOptions ).render( inputField.parentNode );
+
+ // Hide the original input field.
+ hide( inputField, true );
+ inputField.hidden = true;
}
export function renderFields( cardFields ) {
diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js
index d4d9df55f..97bfa6d2c 100644
--- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js
+++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js
@@ -5,7 +5,6 @@ import {
import PaymentButton from '../../../ppcp-button/resources/js/modules/Renderer/PaymentButton';
import widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder';
import UpdatePaymentData from './Helper/UpdatePaymentData';
-import TransactionInfo from './Helper/TransactionInfo';
import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper/CheckoutMethodState';
import { setPayerData } from '../../../ppcp-button/resources/js/modules/Helper/PayerData';
import moduleStorage from './Helper/GooglePayStorage';
@@ -42,17 +41,11 @@ import moduleStorage from './Helper/GooglePayStorage';
*
* @see https://developers.google.com/pay/api/web/reference/client
* @typedef {Object} PaymentsClient
- * @property {Function} createButton - The convenience method is used to
- * generate a Google Pay payment button styled with the latest Google Pay branding for
- * insertion into a webpage.
- * @property {Function} isReadyToPay - Use the isReadyToPay(isReadyToPayRequest)
- * method to determine a user's ability to return a form of payment from the Google Pay API.
- * @property {(Object) => Promise} loadPaymentData - This method presents a Google Pay payment
- * sheet that allows selection of a payment method and optionally configured parameters
- * @property {Function} onPaymentAuthorized - This method is called when a payment is
- * authorized in the payment sheet.
- * @property {Function} onPaymentDataChanged - This method handles payment data changes
- * in the payment sheet such as shipping address and shipping options.
+ * @property {Function} createButton - The convenience method is used to generate a Google Pay payment button styled with the latest Google Pay branding for insertion into a webpage.
+ * @property {Function} isReadyToPay - Use the isReadyToPay(isReadyToPayRequest) method to determine a user's ability to return a form of payment from the Google Pay API.
+ * @property {(Object) => Promise} loadPaymentData - This method presents a Google Pay payment sheet that allows selection of a payment method and optionally configured parameters
+ * @property {Function} onPaymentAuthorized - This method is called when a payment is authorized in the payment sheet.
+ * @property {Function} onPaymentDataChanged - This method handles payment data changes in the payment sheet such as shipping address and shipping options.
*/
/**
@@ -62,18 +55,12 @@ import moduleStorage from './Helper/GooglePayStorage';
* @typedef {Object} TransactionInfo
* @property {string} currencyCode - Required. The ISO 4217 alphabetic currency code.
* @property {string} countryCode - Optional. required for EEA countries,
- * @property {string} transactionId - Optional. A unique ID that identifies a facilitation
- * attempt. Highly encouraged for troubleshooting.
- * @property {string} totalPriceStatus - Required. [ESTIMATED|FINAL] The status of the total price
- * used:
- * @property {string} totalPrice - Required. Total monetary value of the transaction with an
- * optional decimal precision of two decimal places.
- * @property {Array} displayItems - Optional. A list of cart items shown in the payment sheet
- * (e.g. subtotals, sales taxes, shipping charges, discounts etc.).
- * @property {string} totalPriceLabel - Optional. Custom label for the total price within the
- * display items.
- * @property {string} checkoutOption - Optional. Affects the submit button text displayed in the
- * Google Pay payment sheet.
+ * @property {string} transactionId - Optional. A unique ID that identifies a facilitation attempt. Highly encouraged for troubleshooting.
+ * @property {string} totalPriceStatus - Required. [ESTIMATED|FINAL] The status of the total price used.
+ * @property {string} totalPrice - Required. Total monetary value of the transaction with an optional decimal precision of two decimal places.
+ * @property {Array} displayItems - Optional. A list of cart items shown in the payment sheet (e.g. subtotals, sales taxes, shipping charges, discounts etc.).
+ * @property {string} totalPriceLabel - Optional. Custom label for the total price within the display items.
+ * @property {string} checkoutOption - Optional. Affects the submit button text displayed in the Google Pay payment sheet.
*/
function payerDataFromPaymentResponse( response ) {
diff --git a/modules/ppcp-googlepay/resources/js/GooglepayManager.js b/modules/ppcp-googlepay/resources/js/GooglepayManager.js
index 5e79a880e..f9520d23a 100644
--- a/modules/ppcp-googlepay/resources/js/GooglepayManager.js
+++ b/modules/ppcp-googlepay/resources/js/GooglepayManager.js
@@ -1,63 +1,78 @@
-/* global paypal */
-
import buttonModuleWatcher from '../../../ppcp-button/resources/js/modules/ButtonModuleWatcher';
import GooglepayButton from './GooglepayButton';
import ContextHandlerFactory from './Context/ContextHandlerFactory';
class GooglepayManager {
- constructor( buttonConfig, ppcpConfig ) {
+ constructor( namespace, buttonConfig, ppcpConfig ) {
+ this.namespace = namespace;
this.buttonConfig = buttonConfig;
this.ppcpConfig = ppcpConfig;
this.googlePayConfig = null;
this.transactionInfo = null;
this.contextHandler = null;
+
this.buttons = [];
- this.onContextBootstrap = this.onContextBootstrap.bind( this );
- buttonModuleWatcher.watchContextBootstrap( this.onContextBootstrap );
- }
+ buttonModuleWatcher.watchContextBootstrap( async ( bootstrap ) => {
+ this.contextHandler = ContextHandlerFactory.create(
+ bootstrap.context,
+ buttonConfig,
+ ppcpConfig,
+ bootstrap.handler
+ );
- async onContextBootstrap( bootstrap ) {
- this.contextHandler = ContextHandlerFactory.create(
- bootstrap.context,
- this.buttonConfig,
- this.ppcpConfig,
- bootstrap.handler
- );
+ const button = GooglepayButton.createButton(
+ bootstrap.context,
+ bootstrap.handler,
+ buttonConfig,
+ ppcpConfig,
+ this.contextHandler
+ );
- const button = GooglepayButton.createButton(
- bootstrap.context,
- bootstrap.handler,
- this.buttonConfig,
- this.ppcpConfig,
- this.contextHandler
- );
+ this.buttons.push( button );
- this.buttons.push( button );
+ const initButton = () => {
+ button.configure( this.googlePayConfig, this.transactionInfo );
+ button.init();
+ };
- // Ensure googlePayConfig and transactionInfo are loaded.
- await this.init();
+ // Initialize button only if googlePayConfig and transactionInfo are already fetched.
+ if ( this.googlePayConfig && this.transactionInfo ) {
+ initButton();
+ } else {
+ await this.init();
- button.configure( this.googlePayConfig, this.transactionInfo );
- button.init();
+ if ( this.googlePayConfig && this.transactionInfo ) {
+ initButton();
+ }
+ }
+ } );
}
async init() {
try {
if ( ! this.googlePayConfig ) {
// Gets GooglePay configuration of the PayPal merchant.
- this.googlePayConfig = await paypal.Googlepay().config();
-
- if ( ! this.googlePayConfig ) {
- console.error( 'No GooglePayConfig received during init' );
- }
+ this.googlePayConfig = await window[ this.namespace ]
+ .Googlepay()
+ .config();
}
if ( ! this.transactionInfo ) {
this.transactionInfo = await this.fetchTransactionInfo();
+ }
- if ( ! this.transactionInfo ) {
- console.error( 'No transactionInfo found during init' );
+ if ( ! this.googlePayConfig ) {
+ console.error( 'No GooglePayConfig received during init' );
+ } else if ( ! this.transactionInfo ) {
+ console.error( 'No transactionInfo found during init' );
+ } else {
+ for ( const button of this.buttons ) {
+ button.configure(
+ this.googlePayConfig,
+ this.transactionInfo
+ );
+ button.init();
}
}
} catch ( error ) {
diff --git a/modules/ppcp-googlepay/resources/js/GooglepayManagerBlockEditor.js b/modules/ppcp-googlepay/resources/js/GooglepayManagerBlockEditor.js
index 0fbbfbd72..2bf5a55c3 100644
--- a/modules/ppcp-googlepay/resources/js/GooglepayManagerBlockEditor.js
+++ b/modules/ppcp-googlepay/resources/js/GooglepayManagerBlockEditor.js
@@ -2,7 +2,8 @@ import GooglepayButton from './GooglepayButton';
import ContextHandlerFactory from './Context/ContextHandlerFactory';
class GooglepayManagerBlockEditor {
- constructor( buttonConfig, ppcpConfig ) {
+ constructor( namespace, buttonConfig, ppcpConfig ) {
+ this.namespace = namespace;
this.buttonConfig = buttonConfig;
this.ppcpConfig = ppcpConfig;
this.googlePayConfig = null;
@@ -19,7 +20,9 @@ class GooglepayManagerBlockEditor {
async config() {
try {
// Gets GooglePay configuration of the PayPal merchant.
- this.googlePayConfig = await ppcpBlocksEditorPaypalGooglepay.Googlepay().config();
+ this.googlePayConfig = await window[ this.namespace ]
+ .Googlepay()
+ .config();
// Fetch transaction information.
this.transactionInfo = await this.fetchTransactionInfo();
diff --git a/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js b/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js
index 9216ad7c9..de62926ad 100644
--- a/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js
+++ b/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js
@@ -8,6 +8,8 @@ export default class TransactionInfo {
this.#country = country;
this.#currency = currency;
+ shippingFee = this.toAmount( shippingFee );
+ total = this.toAmount( total );
this.shippingFee = shippingFee;
this.amount = total - shippingFee;
}
diff --git a/modules/ppcp-googlepay/resources/js/boot-block.js b/modules/ppcp-googlepay/resources/js/boot-block.js
index 3d465ac93..bc359be7d 100644
--- a/modules/ppcp-googlepay/resources/js/boot-block.js
+++ b/modules/ppcp-googlepay/resources/js/boot-block.js
@@ -4,7 +4,7 @@ import {
registerPaymentMethod,
} from '@woocommerce/blocks-registry';
import { __ } from '@wordpress/i18n';
-import { loadPaypalScript } from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading';
+import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading';
import GooglepayManager from './GooglepayManager';
import { loadCustomScript } from '@paypal/paypal-js';
import GooglepayManagerBlockEditor from './GooglepayManagerBlockEditor';
@@ -14,7 +14,7 @@ const ppcpConfig = ppcpData.scriptData;
const buttonData = wc.wcSettings.getSetting( 'ppcp-googlepay_data' );
const buttonConfig = buttonData.scriptData;
-const dataNamespace = 'ppcpBlocksEditorPaypalGooglepay';
+const namespace = 'ppcpBlocksPaypalGooglepay';
if ( typeof window.PayPalCommerceGateway === 'undefined' ) {
window.PayPalCommerceGateway = ppcpConfig;
@@ -24,14 +24,7 @@ const GooglePayComponent = ( props ) => {
const [ bootstrapped, setBootstrapped ] = useState( false );
const [ paypalLoaded, setPaypalLoaded ] = useState( false );
const [ googlePayLoaded, setGooglePayLoaded ] = useState( false );
-
- const bootstrap = function () {
- const ManagerClass = props.isEditing
- ? GooglepayManagerBlockEditor
- : GooglepayManager;
- const manager = new ManagerClass( buttonConfig, ppcpConfig );
- manager.init();
- };
+ const [ manager, setManager ] = useState( null );
useEffect( () => {
// Load GooglePay SDK
@@ -41,22 +34,36 @@ const GooglePayComponent = ( props ) => {
ppcpConfig.url_params.components += ',googlepay';
- if ( props.isEditing ) {
- ppcpConfig.data_namespace = dataNamespace;
- }
-
// Load PayPal
- loadPaypalScript( ppcpConfig, () => {
- setPaypalLoaded( true );
- } );
+ loadPayPalScript( namespace, ppcpConfig )
+ .then( () => {
+ setPaypalLoaded( true );
+ } )
+ .catch( ( error ) => {
+ console.error( 'Failed to load PayPal script: ', error );
+ } );
}, [] );
useEffect( () => {
- if ( ! bootstrapped && paypalLoaded && googlePayLoaded ) {
- setBootstrapped( true );
- bootstrap();
+ if ( paypalLoaded && googlePayLoaded && ! manager ) {
+ const ManagerClass = props.isEditing
+ ? GooglepayManagerBlockEditor
+ : GooglepayManager;
+ const newManager = new ManagerClass(
+ namespace,
+ buttonConfig,
+ ppcpConfig
+ );
+ setManager( newManager );
}
- }, [ paypalLoaded, googlePayLoaded ] );
+ }, [ paypalLoaded, googlePayLoaded, props.isEditing ] );
+
+ useEffect( () => {
+ if ( manager && ! bootstrapped ) {
+ setBootstrapped( true );
+ manager.init();
+ }
+ }, [ manager, bootstrapped ] );
return (
{
- paypalLoaded = true;
- tryToBoot();
- } );
+ loadPayPalScript( namespace, ppcpConfig )
+ .then( () => {
+ paypalLoaded = true;
+ tryToBoot();
+ } )
+ .catch( ( error ) => {
+ console.error( 'Failed to load PayPal script: ', error );
+ } );
} );
} )( {
buttonConfig: window.wc_ppcp_googlepay,