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-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index e93fa95274..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, @@ -60,6 +61,8 @@ class AxoManager { this.fastlane = new Fastlane( namespace ); this.$ = jQuery; + this.hasProcessedSessionRestore = false; + this.status = { active: false, validEmail: false, @@ -68,6 +71,8 @@ class AxoManager { hasCard: false, }; + this.buttonStateManager = null; + this.clearData(); // TODO - Do we need a public `states` property for this? @@ -249,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 @@ -409,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(); } @@ -526,14 +506,25 @@ class AxoManager { this.readPhoneFromWoo(); log( `Attempt on activation - emailInput: ${ this.emailInput.value }` ); - log( - `this.lastEmailCheckedIdentity: ${ this.lastEmailCheckedIdentity }` - ); - if ( - this.emailInput && - this.lastEmailCheckedIdentity !== this.emailInput.value - ) { - this.onChangeEmail(); + + 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.emailInput.value ) { + if ( + this.buttonStateManager?.shouldProcessEmail( + this.emailInput.value, + this.validateEmailFormat.bind( this ) + ) + ) { + this.onChangeEmail(); + } else { + this.refreshFastlanePrefills(); + } } else { this.refreshFastlanePrefills(); } @@ -652,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; @@ -661,7 +721,11 @@ class AxoManager { await this.connect(); await this.renderWatermark(); this.renderEmailSubmitButton(); + this.initButtonStateManager(); + this.registerEmailEventHandlers(); this.watchEmail(); + + await this.restoreSessionAfterFailure(); } async connect() { @@ -731,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' ); @@ -749,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. */ @@ -829,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; } @@ -877,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(); + } } /** @@ -908,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(); + log( 'Authentication Failed or Canceled' ); + await this.handleFailedAuth(); + this.buttonStateManager?.handleAuthFailureOrCancellation(); } - - 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(); } 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; } } @@ -1371,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(); } }; @@ -1383,6 +1402,121 @@ 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 }` + ); + + // Set processing state for session restoration. + this.buttonStateManager?.markEmailAsProcessing( + 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' + ) { + 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' ); + 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(); + } } export default AxoManager; 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; 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 b53bc5c5d5..58aa7a4e3f 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,26 @@ 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 additional error parameters. + * + * 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 { + $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; + } + /** * 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 ) ) {