From e5561088bba74b15b17bd04cdc7d152f1ce78089 Mon Sep 17 00:00:00 2001 From: Daniel Dudzic Date: Thu, 14 Aug 2025 00:46:07 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20Fastlane=20session=20rest?= =?UTF-8?q?oring=20post=20payment=20failure=20(Block=20Checkout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/js/hooks/useAxoSetup.js | 5 ++ .../js/hooks/useSessionRestoration.js | 87 +++++++++++++++++++ .../src/Endpoint/ReturnUrlEndpoint.php | 23 +++-- .../PayUponInvoice/PayUponInvoiceGateway.php | 2 +- 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 modules/ppcp-axo-block/resources/js/hooks/useSessionRestoration.js diff --git a/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js b/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js index 629ce05d4c..2a8c4a5ff9 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js @@ -11,6 +11,7 @@ import { snapshotFields } from '../helpers/fieldHelpers'; import useCustomerData from './useCustomerData'; import useShippingAddressChange from './useShippingAddressChange'; import useCardChange from './useCardChange'; +import useSessionRestoration from './useSessionRestoration'; /** * Custom hook to set up AXO functionality. @@ -63,6 +64,9 @@ const useAxoSetup = ( // Set up phone sync handler usePhoneSyncHandler( paymentComponent ); + // Set up session restoration + useSessionRestoration( fastlaneSdk ); + // Initialize class toggles on mount useEffect( () => { initializeClassToggles(); @@ -104,6 +108,7 @@ const useAxoSetup = ( setShippingAddress, setCardDetails, paymentComponent, + setCardChangeHandler, ] ); return paypalLoaded; diff --git a/modules/ppcp-axo-block/resources/js/hooks/useSessionRestoration.js b/modules/ppcp-axo-block/resources/js/hooks/useSessionRestoration.js new file mode 100644 index 0000000000..9c0fa45653 --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/hooks/useSessionRestoration.js @@ -0,0 +1,87 @@ +import { useEffect, useRef } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { setIsEmailLookupCompleted, STORE_NAME } from '../stores/axoStore'; +import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; + +/** + * Hook to restore Fastlane session after payment failures using triggerAuthenticationFlow + * Only runs when ppcp_fastlane_error=1 URL parameter is present + * @param {Object} fastlaneSdk - The Fastlane SDK instance + */ +const useSessionRestoration = ( fastlaneSdk ) => { + const { setShippingAddress, setCardDetails, setIsGuest } = + useDispatch( STORE_NAME ); + const hasProcessed = useRef( false ); + + useEffect( () => { + if ( ! fastlaneSdk || hasProcessed.current ) { + return; + } + + const urlParams = new URLSearchParams( window.location.search ); + const hasErrorParam = urlParams.get( 'ppcp_fastlane_error' ) === '1'; + + if ( ! hasErrorParam ) { + return; + } + + // Remove the error parameter from URL + urlParams.delete( 'ppcp_fastlane_error' ); + const newUrl = new URL( window.location ); + newUrl.search = urlParams.toString(); + window.history.replaceState( {}, '', newUrl ); + + hasProcessed.current = true; + + const restoreSession = async () => { + try { + const emailInput = document.getElementById( 'email' ); + + if ( emailInput?.value ) { + const lookupResult = + await fastlaneSdk.identity.lookupCustomerByEmail( + emailInput.value + ); + + wp.data.dispatch( STORE_NAME ).setIsEmailSubmitted( true ); + + if ( lookupResult?.customerContextId ) { + const customerContextId = + lookupResult.customerContextId; + + const authenticatedCustomerResult = + await fastlaneSdk.identity.triggerAuthenticationFlow( + customerContextId + ); + + if ( + authenticatedCustomerResult?.authenticationState === + 'succeeded' + ) { + const { profileData } = authenticatedCustomerResult; + setIsGuest( false ); + + if ( profileData?.shippingAddress ) { + setShippingAddress( + profileData.shippingAddress + ); + } + + if ( profileData?.card ) { + setCardDetails( profileData.card ); + } + + setIsEmailLookupCompleted( true ); + } + } + } + } catch ( error ) { + log( 'Failed to restore Fastlane session', 'warn' ); + } + }; + + restoreSession(); + }, [ fastlaneSdk, setShippingAddress, setCardDetails, setIsGuest ] ); +}; + +export default useSessionRestoration; diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php index b53bc5c5d5..b15e8ff56b 100644 --- a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php +++ b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php @@ -82,7 +82,7 @@ public function handle_request(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['token'] ) ) { wc_add_notice( __( 'Payment session expired. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } $token = sanitize_text_field( wp_unslash( $_GET['token'] ) ); @@ -93,7 +93,7 @@ public function handle_request(): void { } catch ( Exception $exception ) { $this->logger->warning( "Return URL endpoint failed to fetch order $token: " . $exception->getMessage() ); wc_add_notice( __( 'Could not retrieve payment information. Please try again.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } @@ -104,7 +104,7 @@ public function handle_request(): void { } catch ( Exception $e ) { $this->logger->warning( "3DS completion failed for order $token: " . $e->getMessage() ); wc_add_notice( $this->get_3ds_error_message( $e ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } } @@ -128,7 +128,7 @@ public function handle_request(): void { $this->logger->warning( "Return URL endpoint $token: no WC order ID." ); wc_add_notice( __( 'Order information is missing. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } @@ -137,7 +137,7 @@ public function handle_request(): void { $this->logger->warning( "Return URL endpoint $token: WC order $wc_order_id not found." ); wc_add_notice( __( 'Order not found. Please try placing your order again.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } @@ -150,7 +150,7 @@ public function handle_request(): void { $payment_gateway = $this->get_payment_gateway( $wc_order->get_payment_method() ); if ( ! $payment_gateway ) { wc_add_notice( __( 'Payment gateway is unavailable. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } @@ -170,10 +170,19 @@ function( $allowed_hosts ) : array { } wc_add_notice( __( 'Payment processing failed. Please try again or contact support.', 'woocommerce-paypal-payments' ), 'error' ); - wp_safe_redirect( wc_get_checkout_url() ); + wp_safe_redirect( $this->get_checkout_url_with_error() ); exit(); } + /** + * Get checkout URL with Fastlane error parameter. + * + * @return string + */ + private function get_checkout_url_with_error(): string { + return add_query_arg( 'ppcp_fastlane_error', '1', wc_get_checkout_url() ); + } + /** * Check if order needs 3DS completion. * diff --git a/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php b/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php index 952df2c8a3..0661e3273b 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/PayUponInvoice/PayUponInvoiceGateway.php @@ -248,7 +248,7 @@ public function init_form_fields() { public function process_payment( $order_id ) { $wc_order = wc_get_order( $order_id ); // phpcs:disable WordPress.Security.NonceVerification - $birth_date = wc_clean( wp_unslash( $_POST['billing_birth_date'] ?? '' ) ); + $birth_date = wc_clean( wp_unslash( $_POST['billing_birth_date'] ?? '' ) ); $pay_for_order = wc_clean( wp_unslash( $_GET['pay_for_order'] ?? '' ) ); if ( 'true' === $pay_for_order ) { if ( ! $this->checkout_helper->validate_birth_date( $birth_date ) ) { From 3a20cb433acf7731b76b02bdea163b056ad050f7 Mon Sep 17 00:00:00 2001 From: Daniel Dudzic Date: Fri, 15 Aug 2025 02:17:59 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20Add=20Fastlane=20session=20rest?= =?UTF-8?q?oring=20post=20payment=20failure=20(Classic=20Checkout)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/ppcp-axo/resources/js/AxoManager.js | 102 +++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/modules/ppcp-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index e93fa95274..1ffd8fedef 100644 --- a/modules/ppcp-axo/resources/js/AxoManager.js +++ b/modules/ppcp-axo/resources/js/AxoManager.js @@ -60,6 +60,8 @@ class AxoManager { this.fastlane = new Fastlane( namespace ); this.$ = jQuery; + this.hasProcessedSessionRestore = false; + this.status = { active: false, validEmail: false, @@ -529,7 +531,15 @@ class AxoManager { log( `this.lastEmailCheckedIdentity: ${ this.lastEmailCheckedIdentity }` ); - if ( + + const urlParams = new URLSearchParams( window.location.search ); + const hasErrorParam = urlParams.get( 'ppcp_fastlane_error' ) === '1'; + + if ( hasErrorParam ) { + log( + 'Payment failure detected, session restoration will be attempted' + ); + } else if ( this.emailInput && this.lastEmailCheckedIdentity !== this.emailInput.value ) { @@ -662,6 +672,8 @@ class AxoManager { await this.renderWatermark(); this.renderEmailSubmitButton(); this.watchEmail(); + + await this.restoreSessionAfterFailure(); } async connect() { @@ -1383,6 +1395,94 @@ class AxoManager { this.$( '#billing_email_field input' ).on( 'input', reEnableInput ); this.$( '#billing_email_field input' ).on( 'click', reEnableInput ); } + + async restoreSessionAfterFailure() { + if ( ! this.fastlane || this.hasProcessedSessionRestore ) { + return; + } + + const urlParams = new URLSearchParams( window.location.search ); + const hasErrorParam = urlParams.get( 'ppcp_fastlane_error' ) === '1'; + + if ( ! hasErrorParam ) { + return; + } + + urlParams.delete( 'ppcp_fastlane_error' ); + const newUrl = new URL( window.location ); + newUrl.search = urlParams.toString(); + window.history.replaceState( {}, '', newUrl ); + + this.hasProcessedSessionRestore = true; + + try { + if ( this.emailInput?.value ) { + log( + `Restoring Fastlane session for email: ${ this.emailInput.value }` + ); + + const lookupResult = + await this.fastlane.identity.lookupCustomerByEmail( + this.emailInput.value + ); + + if ( lookupResult?.customerContextId ) { + const authenticatedCustomerResult = + await this.fastlane.identity.triggerAuthenticationFlow( + lookupResult.customerContextId + ); + + if ( + authenticatedCustomerResult?.authenticationState === + 'succeeded' + ) { + const { profileData } = authenticatedCustomerResult; + + if ( profileData?.shippingAddress ) { + this.setShipping( profileData.shippingAddress ); + } + + if ( profileData?.card ) { + this.setCard( profileData.card ); + this.setStatus( 'hasCard', true ); + + const cardBillingAddress = + profileData.card?.paymentSource?.card + ?.billingAddress; + if ( cardBillingAddress ) { + const billingData = { + address: cardBillingAddress, + }; + + const phoneNumber = + profileData.shippingAddress?.phoneNumber + ?.nationalNumber; + if ( phoneNumber ) { + billingData.phoneNumber = phoneNumber; + } + + this.setBilling( billingData ); + } + } + + this.setStatus( 'validEmail', true ); + this.setStatus( 'hasProfile', true ); + + this.hideGatewaySelection = true; + this.$( '.wc_payment_methods label' ).hide(); + this.$( '.wc_payment_methods input' ).hide(); + + await this.renderWatermark( false ); + + log( 'Fastlane session successfully restored' ); + } + } + } + } catch ( error ) { + log( 'Failed to restore Fastlane session', 'warn' ); + console.warn( 'Fastlane session restoration error:', error ); + } + } } export default AxoManager; From 499835f617a9d6659944a0274239c0d9b54b7c83 Mon Sep 17 00:00:00 2001 From: Daniel Dudzic Date: Thu, 4 Sep 2025 12:04:46 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=84=20Axo:=20Refactor=20email=20su?= =?UTF-8?q?bmission=20with=20centralized=20ButtonStateManager=20and=20retr?= =?UTF-8?q?y=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/ppcp-axo/resources/js/AxoManager.js | 432 ++++++++++-------- .../resources/js/ButtonStateManager.js | 207 +++++++++ 2 files changed, 440 insertions(+), 199 deletions(-) create mode 100644 modules/ppcp-axo/resources/js/ButtonStateManager.js diff --git a/modules/ppcp-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index 1ffd8fedef..a33057974d 100644 --- a/modules/ppcp-axo/resources/js/AxoManager.js +++ b/modules/ppcp-axo/resources/js/AxoManager.js @@ -4,6 +4,7 @@ import DomElementCollection from './Components/DomElementCollection'; import ShippingView from './Views/ShippingView'; import BillingView from './Views/BillingView'; import CardView from './Views/CardView'; +import ButtonStateManager from './ButtonStateManager'; import PayPalInsights from './Insights/PayPalInsights'; import { disable, @@ -70,6 +71,8 @@ class AxoManager { hasCard: false, }; + this.buttonStateManager = null; + this.clearData(); // TODO - Do we need a public `states` property for this? @@ -251,41 +254,19 @@ class AxoManager { this.cardView.refresh(); } ); - // Prevents sending checkout form when pressing Enter key on input field - // and triggers customer lookup - this.$( 'form.woocommerce-checkout input' ).on( - 'keydown', - async ( ev ) => { - if ( - ev.key === 'Enter' && - getCurrentPaymentMethod() === 'ppcp-axo-gateway' - ) { - ev.preventDefault(); - log( - `Enter key attempt - emailInput: ${ this.emailInput.value }` - ); - log( - `this.lastEmailCheckedIdentity: ${ this.lastEmailCheckedIdentity }` - ); - this.validateEmail( this.el.fieldBillingEmail.selector ); - if ( - this.emailInput && - this.lastEmailCheckedIdentity !== this.emailInput.value - ) { - await this.onChangeEmail(); - } - } - } - ); - this.reEnableEmailInput(); // Clear last email checked identity when email field is focused. this.$( '#billing_email_field input' ).on( 'focus', ( ev ) => { - log( - `Clear the last email checked: ${ this.lastEmailCheckedIdentity }` - ); - this.lastEmailCheckedIdentity = ''; + if ( ! this.buttonStateManager?.isProcessing() ) { + this.buttonStateManager?.clearLastProcessedEmail(); + if ( + this.emailInput?.value && + this.validateEmailFormat( this.emailInput.value ) + ) { + this.buttonStateManager?.setReady(); + } + } } ); // Listening to status update event @@ -411,9 +392,6 @@ class AxoManager { if ( scenario.axoPaymentContainer ) { this.el.paymentContainer.show(); this.el.gatewayDescription.hide(); - document - .querySelector( this.el.billingEmailSubmitButton.selector ) - .setAttribute( 'disabled', 'disabled' ); } else { this.el.paymentContainer.hide(); } @@ -528,9 +506,6 @@ class AxoManager { this.readPhoneFromWoo(); log( `Attempt on activation - emailInput: ${ this.emailInput.value }` ); - log( - `this.lastEmailCheckedIdentity: ${ this.lastEmailCheckedIdentity }` - ); const urlParams = new URLSearchParams( window.location.search ); const hasErrorParam = urlParams.get( 'ppcp_fastlane_error' ) === '1'; @@ -539,11 +514,17 @@ class AxoManager { log( 'Payment failure detected, session restoration will be attempted' ); - } else if ( - this.emailInput && - this.lastEmailCheckedIdentity !== this.emailInput.value - ) { - this.onChangeEmail(); + } else if ( this.emailInput && this.emailInput.value ) { + if ( + this.buttonStateManager?.shouldProcessEmail( + this.emailInput.value, + this.validateEmailFormat.bind( this ) + ) + ) { + this.onChangeEmail(); + } else { + this.refreshFastlanePrefills(); + } } else { this.refreshFastlanePrefills(); } @@ -662,6 +643,75 @@ class AxoManager { } } + initButtonStateManager() { + if ( ! this.buttonStateManager ) { + this.buttonStateManager = new ButtonStateManager( + this.el.billingEmailSubmitButton.selector, + this.el.billingEmailSubmitButtonSpinner.selector + ); + log( 'Button state manager initialized' ); + } + } + + registerEmailEventHandlers() { + const emailInput = document.querySelector( + '#billing_email_field input' + ); + + if ( emailInput ) { + emailInput.addEventListener( 'keydown', async ( ev ) => { + if ( + ev.key === 'Enter' && + getCurrentPaymentMethod() === 'ppcp-axo-gateway' + ) { + ev.preventDefault(); + ev.stopPropagation(); + + log( + `Enter key on email field - value: ${ this.emailInput.value }` + ); + this.validateEmail( this.el.fieldBillingEmail.selector ); + + if ( + this.emailInput && + this.buttonStateManager?.shouldAllowRetry( + this.emailInput.value, + this.validateEmailFormat.bind( this ) + ) + ) { + await this.onChangeEmail(); + } + + return false; + } + } ); + } + + const submitButton = document.querySelector( + this.el.billingEmailSubmitButton.selector + ); + + if ( submitButton ) { + submitButton.addEventListener( 'click', async ( ev ) => { + ev.preventDefault(); + + log( + `Submit button clicked - email: ${ this.emailInput.value }` + ); + + if ( + this.emailInput && + this.buttonStateManager?.shouldAllowRetry( + this.emailInput.value, + this.validateEmailFormat.bind( this ) + ) + ) { + await this.onChangeEmail(); + } + } ); + } + } + async initFastlane() { if ( this.initialized ) { return; @@ -671,6 +721,8 @@ class AxoManager { await this.connect(); await this.renderWatermark(); this.renderEmailSubmitButton(); + this.initButtonStateManager(); + this.registerEmailEventHandlers(); this.watchEmail(); await this.restoreSessionAfterFailure(); @@ -743,7 +795,9 @@ class AxoManager { // Reorder button to ensure it's before the watermark container wrapper.insertBefore( buttonElement, watermarkContainer ); - buttonElement.offsetHeight; + // eslint-disable-next-line no-unused-expressions + buttonElement.offsetHeight; // Force layout reflow + buttonElement.classList.remove( 'ppcp-axo-billing-email-submit-button-hidden' ); @@ -761,30 +815,25 @@ class AxoManager { log( `Change event attempt - emailInput: ${ this.emailInput.value }` ); - log( - `this.lastEmailCheckedIdentity: ${ this.lastEmailCheckedIdentity }` - ); + if ( this.emailInput && - this.lastEmailCheckedIdentity !== this.emailInput.value + this.buttonStateManager?.shouldProcessEmail( + this.emailInput.value, + this.validateEmailFormat.bind( this ) + ) ) { this.validateEmail( this.el.fieldBillingEmail.selector ); this.onChangeEmail(); } } ); - log( - `Last, this.emailInput.value attempt - emailInput: ${ this.emailInput.value }` - ); - log( - `this.lastEmailCheckedIdentity: ${ this.lastEmailCheckedIdentity }` - ); + log( `Checking initial email value: ${ this.emailInput.value }` ); if ( this.emailInput.value ) { this.onChangeEmail(); } } } - /** * Locates the WooCommerce checkout "billing phone" field and adds event listeners to it. */ @@ -841,46 +890,50 @@ class AxoManager { return; } - if ( this.data.email === this.emailInput.value ) { - log( 'Email has not changed since last validation.' ); + const currentEmail = this.emailInput.value; + + // Check if we should process this email using ButtonStateManager + if ( + ! this.buttonStateManager?.shouldProcessEmail( + currentEmail, + this.validateEmailFormat.bind( this ) + ) + ) { + log( + 'Email processing skipped - already processing or same email' + ); return; } - log( - `Email changed: ${ - this.emailInput ? this.emailInput.value : '' - }` - ); - this.clearData(); + log( `Email changed: ${ currentEmail || '' }` ); - this.emailInput.value = this.stripSpaces( this.emailInput.value ); + this.buttonStateManager.markEmailAsProcessing( currentEmail ); + this.clearData(); + this.emailInput.value = this.stripSpaces( this.emailInput.value ); this.$( this.el.paymentContainer.selector + '-details' ).html( '' ); this.removeFastlaneComponent(); - this.setStatus( 'validEmail', false ); this.setStatus( 'hasProfile', false ); - this.hideGatewaySelection = false; - this.lastEmailCheckedIdentity = this.emailInput.value; - if ( ! this.emailInput.value || ! this.emailInput.checkValidity() || ! this.validateEmailFormat( this.emailInput.value ) ) { log( 'The email address is not valid.' ); + this.buttonStateManager?.setDisabled(); return; } this.data.email = this.emailInput.value; this.billingView.setData( this.data ); - this.readPhoneFromWoo(); if ( ! this.fastlane.identity ) { log( 'Not initialized.' ); + this.buttonStateManager?.setDisabled(); return; } @@ -889,18 +942,15 @@ class AxoManager { } ); this.disableGatewaySelection(); - this.spinnerToggleLoaderAndOverlay( - this.el.billingEmailSubmitButtonSpinner, - 'loader', - 'ppcp-axo-overlay' - ); - await this.lookupCustomerByEmail(); - this.spinnerToggleLoaderAndOverlay( - this.el.billingEmailSubmitButtonSpinner, - 'loader', - 'ppcp-axo-overlay' - ); - this.enableGatewaySelection(); + + try { + await this.lookupCustomerByEmail(); + } catch ( error ) { + log( `Email lookup failed: ${ error.message }`, 'error' ); + this.buttonStateManager?.handleEmailLookupFailure(); + } finally { + this.enableGatewaySelection(); + } } /** @@ -920,93 +970,49 @@ class AxoManager { } async lookupCustomerByEmail() { - const lookupResponse = - await this.fastlane.identity.lookupCustomerByEmail( - this.emailInput.value - ); - - log( `lookupCustomerByEmail: ${ JSON.stringify( lookupResponse ) }` ); + try { + const lookupResponse = + await this.fastlane.identity.lookupCustomerByEmail( + this.emailInput.value + ); - if ( lookupResponse.customerContextId ) { - // Email is associated with a Connect profile or a PayPal member. - // Authenticate the customer to get access to their profile. log( - 'Email is associated with a Connect profile or a PayPal member' + `lookupCustomerByEmail: ${ JSON.stringify( lookupResponse ) }` ); - const authResponse = - await this.fastlane.identity.triggerAuthenticationFlow( - lookupResponse.customerContextId + if ( lookupResponse.customerContextId ) { + log( + 'Email is associated with a Connect profile or a PayPal member' ); - log( - `AuthResponse - triggerAuthenticationFlow: ${ JSON.stringify( - authResponse - ) }` - ); + const authResponse = + await this.fastlane.identity.triggerAuthenticationFlow( + lookupResponse.customerContextId + ); - if ( authResponse.authenticationState === 'succeeded' ) { - const shippingData = authResponse.profileData.shippingAddress; - if ( shippingData ) { - this.setShipping( shippingData ); - } + log( + `AuthResponse - triggerAuthenticationFlow: ${ JSON.stringify( + authResponse + ) }` + ); - if ( authResponse.profileData.card ) { - this.setStatus( 'hasCard', true ); + if ( authResponse.authenticationState === 'succeeded' ) { + await this.handleSuccessfulAuth( authResponse ); + this.buttonStateManager?.handleSuccess(); } else { - await this.initializeFastlaneComponent(); - } - - const cardBillingAddress = - authResponse.profileData?.card?.paymentSource?.card - ?.billingAddress; - if ( cardBillingAddress ) { - this.setCard( authResponse.profileData.card ); - - const billingData = { - address: cardBillingAddress, - }; - const phoneNumber = - authResponse.profileData?.shippingAddress?.phoneNumber - ?.nationalNumber ?? ''; - if ( phoneNumber ) { - billingData.phoneNumber = phoneNumber; - } - - this.setBilling( billingData ); + log( 'Authentication Failed or Canceled' ); + await this.handleFailedAuth(); + this.buttonStateManager?.handleAuthFailureOrCancellation(); } - - this.setStatus( 'validEmail', true ); - this.setStatus( 'hasProfile', true ); - - this.hideGatewaySelection = true; - this.$( '.wc_payment_methods label' ).hide(); - this.$( '.wc_payment_methods input' ).hide(); - - await this.renderWatermark( false ); - - this.rerender(); } else { - // authentication failed or canceled by the customer - // set status as guest customer - log( 'Authentication Failed' ); - - this.setStatus( 'validEmail', true ); - this.setStatus( 'hasProfile', false ); - - await this.renderWatermark( true ); - await this.initializeFastlaneComponent(); + log( 'No profile found with this email address.' ); + await this.handleGuestCustomer(); + this.buttonStateManager?.handleSuccess(); } - } else { - // No profile found with this email address. - // This is a guest customer. - log( 'No profile found with this email address.' ); - - this.setStatus( 'validEmail', true ); - this.setStatus( 'hasProfile', false ); - - await this.renderWatermark( true ); - await this.initializeFastlaneComponent(); + } catch ( error ) { + log( `lookupCustomerByEmail error: ${ error.message }`, 'error' ); + this.buttonStateManager?.handleEmailLookupFailure(); + throw error; } } @@ -1383,11 +1389,12 @@ class AxoManager { reEnableEmailInput() { const reEnableInput = ( ev ) => { - const submitButton = document.querySelector( - this.el.billingEmailSubmitButton.selector - ); - if ( submitButton.hasAttribute( 'disabled' ) ) { - submitButton.removeAttribute( 'disabled' ); + if ( + ! this.buttonStateManager?.isProcessing() && + this.emailInput?.value && + this.validateEmailFormat( this.emailInput.value ) + ) { + this.buttonStateManager?.setReady(); } }; @@ -1421,6 +1428,11 @@ class AxoManager { `Restoring Fastlane session for email: ${ this.emailInput.value }` ); + // Set processing state for session restoration. + this.buttonStateManager?.markEmailAsProcessing( + this.emailInput.value + ); + const lookupResult = await this.fastlane.identity.lookupCustomerByEmail( this.emailInput.value @@ -1436,52 +1448,74 @@ class AxoManager { authenticatedCustomerResult?.authenticationState === 'succeeded' ) { - const { profileData } = authenticatedCustomerResult; - - if ( profileData?.shippingAddress ) { - this.setShipping( profileData.shippingAddress ); - } - - if ( profileData?.card ) { - this.setCard( profileData.card ); - this.setStatus( 'hasCard', true ); - - const cardBillingAddress = - profileData.card?.paymentSource?.card - ?.billingAddress; - if ( cardBillingAddress ) { - const billingData = { - address: cardBillingAddress, - }; - - const phoneNumber = - profileData.shippingAddress?.phoneNumber - ?.nationalNumber; - if ( phoneNumber ) { - billingData.phoneNumber = phoneNumber; - } - - this.setBilling( billingData ); - } - } - - this.setStatus( 'validEmail', true ); - this.setStatus( 'hasProfile', true ); - - this.hideGatewaySelection = true; - this.$( '.wc_payment_methods label' ).hide(); - this.$( '.wc_payment_methods input' ).hide(); - - await this.renderWatermark( false ); - + await this.handleSuccessfulAuth( + authenticatedCustomerResult + ); + this.buttonStateManager?.handleSuccess(); log( 'Fastlane session successfully restored' ); + } else { + await this.handleFailedAuth(); + this.buttonStateManager?.handleAuthFailureOrCancellation(); } + } else { + await this.handleGuestCustomer(); + this.buttonStateManager?.handleSuccess(); } } } catch ( error ) { log( 'Failed to restore Fastlane session', 'warn' ); - console.warn( 'Fastlane session restoration error:', error ); + log( 'Fastlane session restoration error:', 'error' ); + this.buttonStateManager?.handleEmailLookupFailure(); + } + } + + async handleSuccessfulAuth( authResponse ) { + const shippingData = authResponse.profileData.shippingAddress; + if ( shippingData ) { + this.setShipping( shippingData ); + } + + if ( authResponse.profileData.card ) { + this.setStatus( 'hasCard', true ); + } else { + await this.initializeFastlaneComponent(); + } + + const cardBillingAddress = + authResponse.profileData?.card?.paymentSource?.card?.billingAddress; + if ( cardBillingAddress ) { + this.setCard( authResponse.profileData.card ); + const billingData = { address: cardBillingAddress }; + const phoneNumber = + authResponse.profileData?.shippingAddress?.phoneNumber + ?.nationalNumber ?? ''; + if ( phoneNumber ) { + billingData.phoneNumber = phoneNumber; + } + this.setBilling( billingData ); } + + this.setStatus( 'validEmail', true ); + this.setStatus( 'hasProfile', true ); + this.hideGatewaySelection = true; + this.$( '.wc_payment_methods label' ).hide(); + this.$( '.wc_payment_methods input' ).hide(); + await this.renderWatermark( false ); + this.rerender(); + } + + async handleFailedAuth() { + this.setStatus( 'validEmail', true ); + this.setStatus( 'hasProfile', false ); + await this.renderWatermark( true ); + await this.initializeFastlaneComponent(); + } + + async handleGuestCustomer() { + this.setStatus( 'validEmail', true ); + this.setStatus( 'hasProfile', false ); + await this.renderWatermark( true ); + await this.initializeFastlaneComponent(); } } diff --git a/modules/ppcp-axo/resources/js/ButtonStateManager.js b/modules/ppcp-axo/resources/js/ButtonStateManager.js new file mode 100644 index 0000000000..92d453e999 --- /dev/null +++ b/modules/ppcp-axo/resources/js/ButtonStateManager.js @@ -0,0 +1,207 @@ +import { log } from './Helper/Debug'; + +/** + * Manages the state and UI of the email submit button. + * Handles processing states, validation, and duplicate submission prevention. + */ +class ButtonStateManager { + constructor( submitButtonSelector, spinnerSelector ) { + this.submitButtonSelector = submitButtonSelector; + this.spinnerSelector = spinnerSelector; + + this.state = { + isProcessing: false, + canSubmit: false, + lastProcessedEmail: null, + }; + } + + /** + * Get current button state (read-only). + */ + getState() { + return { ...this.state }; + } + + /** + * Centralized button UI management based on current state. + */ + updateButtonUI() { + const submitButton = document.querySelector( + this.submitButtonSelector + ); + if ( ! submitButton ) { + log( 'Submit button not found, skipping UI update', 'warn' ); + return; + } + + const spinner = document.querySelector( this.spinnerSelector ); + + if ( this.state.isProcessing ) { + // Processing state - disabled with spinner + submitButton.setAttribute( 'disabled', 'disabled' ); + spinner?.classList.add( 'loader', 'ppcp-axo-overlay' ); + submitButton.classList.add( 'processing' ); + log( 'Button set to processing state' ); + } else if ( this.state.canSubmit ) { + // Ready state - enabled + submitButton.removeAttribute( 'disabled' ); + spinner?.classList.remove( 'loader', 'ppcp-axo-overlay' ); + submitButton.classList.remove( 'processing' ); + log( 'Button set to ready state' ); + } else { + // Default/disabled state + submitButton.setAttribute( 'disabled', 'disabled' ); + spinner?.classList.remove( 'loader', 'ppcp-axo-overlay' ); + submitButton.classList.remove( 'processing' ); + log( 'Button set to disabled state' ); + } + } + + /** + * Set button to processing state (disabled with spinner). + */ + setProcessing() { + this.state.isProcessing = true; + this.state.canSubmit = false; + this.updateButtonUI(); + log( 'Button state changed to: processing' ); + } + + /** + * Set button to ready state (enabled and clickable). + */ + setReady() { + this.state.isProcessing = false; + this.state.canSubmit = true; + this.updateButtonUI(); + log( 'Button state changed to: ready' ); + } + + /** + * Set button to disabled state (disabled, no spinner). + */ + setDisabled() { + this.state.isProcessing = false; + this.state.canSubmit = false; + this.updateButtonUI(); + log( 'Button state changed to: disabled' ); + } + + /** + * Check if we should process the email (prevents duplicate processing). + * @param {string} email - Email to check. + * @param {Function} validateEmailFormat - Email validation function. + * @return {boolean} True if email should be processed. + */ + shouldProcessEmail( email, validateEmailFormat ) { + const shouldProcess = + ! this.state.isProcessing && + this.state.lastProcessedEmail !== email && + email && + validateEmailFormat( email ); + + log( + `shouldProcessEmail: ${ shouldProcess } (processing: ${ this.state.isProcessing }, lastEmail: ${ this.state.lastProcessedEmail }, currentEmail: ${ email })` + ); + return shouldProcess; + } + + /** + * Check if we should allow retry after failure/cancellation. + * @param {string} email - Email to check. + * @param {Function} validateEmailFormat - Email validation function. + * @return {boolean} True if retry should be allowed. + */ + shouldAllowRetry( email, validateEmailFormat ) { + const shouldRetry = + ! this.state.isProcessing && email && validateEmailFormat( email ); + + log( + `shouldAllowRetry: ${ shouldRetry } (processing: ${ this.state.isProcessing }, email: ${ email })` + ); + return shouldRetry; + } + + /** + * Mark email as being processed to prevent duplicates. + * @param {string} email - Email being processed. + */ + markEmailAsProcessing( email ) { + this.state.lastProcessedEmail = email; + this.setProcessing(); + log( `Email marked as processing: ${ email }` ); + } + + /** + * Clear the last processed email (allows retry). + */ + clearLastProcessedEmail() { + const previousEmail = this.state.lastProcessedEmail; + this.state.lastProcessedEmail = null; + log( `Cleared last processed email: ${ previousEmail }` ); + } + + /** + * Handle authentication failure or cancellation - allows retry. + */ + handleAuthFailureOrCancellation() { + log( 'Handling auth failure/cancellation - allowing retry' ); + this.clearLastProcessedEmail(); + this.setReady(); + + // Force UI update to ensure button is actually enabled. + setTimeout( () => { + this.updateButtonUI(); + log( 'Forced button UI update after cancellation' ); + }, 100 ); + } + + /** + * Handle email lookup failure - disables button. + */ + handleEmailLookupFailure() { + log( 'Handling email lookup failure - disabling button' ); + this.clearLastProcessedEmail(); + this.setDisabled(); + } + + /** + * Handle successful processing - enables button for next action. + */ + handleSuccess() { + log( 'Handling successful processing' ); + this.setReady(); + } + + /** + * Reset to initial state. + */ + reset() { + this.state = { + isProcessing: false, + canSubmit: false, + lastProcessedEmail: null, + }; + this.updateButtonUI(); + log( 'Button state reset to initial state' ); + } + + /** + * Check if currently processing. + * @return {boolean} True if the button is currently in the processing state. + */ + isProcessing() { + return this.state.isProcessing; + } + + /** + * Check if button can be submitted. + * @return {boolean} True if the button can be submitted and is not in the processing state. + */ + canSubmit() { + return this.state.canSubmit && ! this.state.isProcessing; + } +} + +export default ButtonStateManager; From 67881e94e98fa8e0870ac74bcdae09d9e2e9f408 Mon Sep 17 00:00:00 2001 From: Daniel Dudzic Date: Wed, 24 Sep 2025 02:13:46 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20Add=20Fastlane=20error=20param?= =?UTF-8?q?=20via=20filter=20to=20checkout=20error=20redirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/ppcp-axo/src/AxoModule.php | 17 +++++++++++++++++ .../src/Endpoint/ReturnUrlEndpoint.php | 13 ++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php index 1b9b6f59a8..789e974c53 100644 --- a/modules/ppcp-axo/src/AxoModule.php +++ b/modules/ppcp-axo/src/AxoModule.php @@ -347,6 +347,23 @@ function () use ( $c ) { } ); + add_filter( + 'ppcp_return_url_error_args', + /** + * Param types removed to avoid third-party issues. + * + * @psalm-suppress MissingClosureParamType + */ + function( $args, $endpoint ) use ( $c ): array { + if ( $this->should_render_fastlane( $c ) ) { + $args['ppcp_fastlane_error'] = '1'; + } + return $args; + }, + 10, + 2 + ); + // Remove Fastlane on the Pay for Order page. add_filter( 'woocommerce_available_payment_gateways', diff --git a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php index b15e8ff56b..58aa7a4e3f 100644 --- a/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php +++ b/modules/ppcp-wc-gateway/src/Endpoint/ReturnUrlEndpoint.php @@ -175,12 +175,19 @@ function( $allowed_hosts ) : array { } /** - * Get checkout URL with Fastlane error parameter. + * Get checkout URL with additional error parameters. * - * @return string + * Applies the 'ppcp_return_url_error_args' filter to allow external modules to add error parameters. + * + * @return string Checkout URL with error query arguments, if any. */ private function get_checkout_url_with_error(): string { - return add_query_arg( 'ppcp_fastlane_error', '1', wc_get_checkout_url() ); + $url = wc_get_checkout_url(); + $args = apply_filters( 'ppcp_return_url_error_args', array(), $this ); + if ( ! empty( $args ) ) { + $url = add_query_arg( $args, $url ); + } + return $url; } /**