diff --git a/modules/ppcp-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index d315ce374..551566042 100644 --- a/modules/ppcp-axo/resources/js/AxoManager.js +++ b/modules/ppcp-axo/resources/js/AxoManager.js @@ -801,8 +801,6 @@ class AxoManager { } async onChangeEmail() { - this.clearData(); - if ( ! this.status.active ) { log( 'Email checking skipped, AXO not active.' ); return; @@ -813,11 +811,17 @@ class AxoManager { return; } + if ( this.data.email === this.emailInput.value ) { + log( 'Email has not changed since last validation.' ); + return; + } + log( `Email changed: ${ this.emailInput ? this.emailInput.value : '' }` ); + 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/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 ) {