diff --git a/modules/ppcp-axo-block/resources/css/gateway.scss b/modules/ppcp-axo-block/resources/css/gateway.scss index 36817cb1b..42a14bd6b 100644 --- a/modules/ppcp-axo-block/resources/css/gateway.scss +++ b/modules/ppcp-axo-block/resources/css/gateway.scss @@ -301,3 +301,13 @@ a.wc-block-axo-change-link { #shipping-fields .wc-block-components-checkout-step__heading { display: flex; } + +// 11. Fastlane modal info message fix +.wc-block-components-text-input { + .wc-block-components-form &, + & { + paypal-watermark { + white-space: wrap; + } + } +} diff --git a/modules/ppcp-axo-block/resources/js/components/Card/Card.js b/modules/ppcp-axo-block/resources/js/components/Card/Card.js index 51dfa15ae..3bf39b10b 100644 --- a/modules/ppcp-axo-block/resources/js/components/Card/Card.js +++ b/modules/ppcp-axo-block/resources/js/components/Card/Card.js @@ -5,7 +5,7 @@ import { STORE_NAME } from '../../stores/axoStore'; const cardIcons = { VISA: 'visa-light.svg', - MASTER_CARD: 'mastercard-light.svg', + MASTERCARD: 'mastercard-light.svg', AMEX: 'amex-light.svg', DISCOVER: 'discover-light.svg', DINERS: 'dinersclub-light.svg', diff --git a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButton.js b/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButton.js index 1779d67b6..c2a4eaa65 100644 --- a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButton.js +++ b/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButton.js @@ -1,6 +1,13 @@ import { createElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +/** + * Renders a button to change the selected card in the checkout process. + * + * @param {Object} props + * @param {Function} props.onChangeButtonClick - Callback function to handle the click event. + * @return {JSX.Element} The rendered button as an anchor tag. + */ const CardChangeButton = ( { onChangeButtonClick } ) => createElement( 'a', @@ -9,7 +16,9 @@ const CardChangeButton = ( { onChangeButtonClick } ) => 'wc-block-checkout-axo-block-card__edit wc-block-axo-change-link', role: 'button', onClick: ( event ) => { + // Prevent default anchor behavior event.preventDefault(); + // Call the provided click handler onChangeButtonClick(); }, }, diff --git a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js b/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js index c38e6c0b6..2b6deba4f 100644 --- a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js +++ b/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js @@ -1,6 +1,13 @@ import { createElement, createRoot, useEffect } from '@wordpress/element'; import CardChangeButton from './CardChangeButton'; +/** + * Manages the insertion and removal of the CardChangeButton in the DOM. + * + * @param {Object} props + * @param {Function} props.onChangeButtonClick - Callback function for when the card change button is clicked. + * @return {null} This component doesn't render any visible elements directly. + */ const CardChangeButtonManager = ( { onChangeButtonClick } ) => { useEffect( () => { const radioLabelElement = document.getElementById( @@ -8,14 +15,17 @@ const CardChangeButtonManager = ( { onChangeButtonClick } ) => { ); if ( radioLabelElement ) { + // Check if the change button doesn't already exist if ( ! radioLabelElement.querySelector( '.wc-block-checkout-axo-block-card__edit' ) ) { + // Create a new container for the button const buttonContainer = document.createElement( 'div' ); radioLabelElement.appendChild( buttonContainer ); + // Create a React root and render the CardChangeButton const root = createRoot( buttonContainer ); root.render( createElement( CardChangeButton, { onChangeButtonClick } ) @@ -23,6 +33,7 @@ const CardChangeButtonManager = ( { onChangeButtonClick } ) => { } } + // Cleanup function to remove the button when the component unmounts return () => { const button = document.querySelector( '.wc-block-checkout-axo-block-card__edit' @@ -33,6 +44,7 @@ const CardChangeButtonManager = ( { onChangeButtonClick } ) => { }; }, [ onChangeButtonClick ] ); + // This component doesn't render anything directly return null; }; diff --git a/modules/ppcp-axo-block/resources/js/components/Card/utils.js b/modules/ppcp-axo-block/resources/js/components/Card/utils.js index 93f903142..915511885 100644 --- a/modules/ppcp-axo-block/resources/js/components/Card/utils.js +++ b/modules/ppcp-axo-block/resources/js/components/Card/utils.js @@ -1,18 +1,31 @@ import { createElement, createRoot } from '@wordpress/element'; import CardChangeButtonManager from './CardChangeButtonManager'; +/** + * Injects a card change button into the DOM. + * + * @param {Function} onChangeButtonClick - Callback function for when the card change button is clicked. + */ export const injectCardChangeButton = ( onChangeButtonClick ) => { + // Create a container for the button const container = document.createElement( 'div' ); document.body.appendChild( container ); + + // Render the CardChangeButtonManager in the new container createRoot( container ).render( createElement( CardChangeButtonManager, { onChangeButtonClick } ) ); }; +/** + * Removes the card change button from the DOM if it exists. + */ export const removeCardChangeButton = () => { const button = document.querySelector( '.wc-block-checkout-axo-block-card__edit' ); + + // Remove the button's parent node if it exists if ( button && button.parentNode ) { button.parentNode.remove(); } diff --git a/modules/ppcp-axo-block/resources/js/components/EmailButton/EmailButton.js b/modules/ppcp-axo-block/resources/js/components/EmailButton/EmailButton.js index ab41f067e..a7fcbb86d 100644 --- a/modules/ppcp-axo-block/resources/js/components/EmailButton/EmailButton.js +++ b/modules/ppcp-axo-block/resources/js/components/EmailButton/EmailButton.js @@ -2,7 +2,15 @@ import { STORE_NAME } from '../../stores/axoStore'; import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +/** + * Renders a submit button for email input in the AXO checkout process. + * + * @param {Object} props + * @param {Function} props.handleSubmit - Function to handle button click/submit. + * @return {JSX.Element|null} The rendered button or null if conditions are not met. + */ const EmailButton = ( { handleSubmit } ) => { + // Select relevant states from the AXO store const { isGuest, isAxoActive, isEmailSubmitted } = useSelect( ( select ) => ( { isGuest: select( STORE_NAME ).getIsGuest(), @@ -11,6 +19,7 @@ const EmailButton = ( { handleSubmit } ) => { } ) ); + // Only render the button for guests when AXO is active if ( ! isGuest || ! isAxoActive ) { return null; } @@ -24,6 +33,7 @@ const EmailButton = ( { handleSubmit } ) => { }` } disabled={ isEmailSubmitted } > + { /* Button text */ } { > { __( 'Continue', 'woocommerce-paypal-payments' ) } + { /* Loading spinner */ } { isEmailSubmitted && ( { if ( ! emailInput ) { emailInput = document.getElementById( 'email' ); @@ -18,6 +24,11 @@ const getEmailInput = () => { return emailInput; }; +/** + * Sets up email functionality for AXO checkout. + * + * @param {Function} onEmailSubmit - Callback function to handle email submission. + */ export const setupEmailFunctionality = ( onEmailSubmit ) => { const input = getEmailInput(); if ( ! input ) { @@ -28,6 +39,7 @@ export const setupEmailFunctionality = ( onEmailSubmit ) => { return; } + // Handler for email submission const handleEmailSubmit = async () => { const isEmailSubmitted = wp.data .select( STORE_NAME ) @@ -50,6 +62,7 @@ export const setupEmailFunctionality = ( onEmailSubmit ) => { } }; + // Set up keydown handler for Enter key keydownHandler = ( event ) => { const isAxoActive = wp.data.select( STORE_NAME ).getIsAxoActive(); if ( event.key === 'Enter' && isAxoActive ) { @@ -78,6 +91,7 @@ export const setupEmailFunctionality = ( onEmailSubmit ) => { ); } + // Function to render the EmailButton const renderButton = () => { if ( submitButtonReference.root ) { submitButtonReference.root.render( @@ -90,12 +104,15 @@ export const setupEmailFunctionality = ( onEmailSubmit ) => { renderButton(); - // Subscribe to state changes + // Subscribe to state changes and re-render button submitButtonReference.unsubscribe = wp.data.subscribe( () => { renderButton(); } ); }; +/** + * Removes email functionality and cleans up event listeners and DOM elements. + */ export const removeEmailFunctionality = () => { const input = getEmailInput(); if ( input && keydownHandler ) { @@ -120,6 +137,11 @@ export const removeEmailFunctionality = () => { keydownHandler = null; }; +/** + * Checks if email functionality is currently set up. + * + * @return {boolean} True if email functionality is set up, false otherwise. + */ export const isEmailFunctionalitySetup = () => { return !! submitButtonReference.root; }; diff --git a/modules/ppcp-axo-block/resources/js/components/Payment/Payment.js b/modules/ppcp-axo-block/resources/js/components/Payment/Payment.js index d6c96c51c..e1191c8ce 100644 --- a/modules/ppcp-axo-block/resources/js/components/Payment/Payment.js +++ b/modules/ppcp-axo-block/resources/js/components/Payment/Payment.js @@ -4,8 +4,18 @@ import { __ } from '@wordpress/i18n'; import { Card } from '../Card'; import { STORE_NAME } from '../../stores/axoStore'; +/** + * Renders the payment component based on the user's state (guest or authenticated). + * + * @param {Object} props + * @param {Object} props.fastlaneSdk - The Fastlane SDK instance. + * @param {Function} props.onPaymentLoad - Callback function when payment component is loaded. + * @return {JSX.Element} The rendered payment component. + */ export const Payment = ( { fastlaneSdk, onPaymentLoad } ) => { const [ isCardElementReady, setIsCardElementReady ] = useState( false ); + + // Select relevant states from the AXO store const { isGuest, isEmailLookupCompleted } = useSelect( ( select ) => ( { isGuest: select( STORE_NAME ).getIsGuest(), @@ -31,16 +41,22 @@ export const Payment = ( { fastlaneSdk, onPaymentLoad } ) => { onPaymentLoad, ] ); + // Set card element ready when guest email lookup is completed useEffect( () => { if ( isGuest && isEmailLookupCompleted ) { setIsCardElementReady( true ); } }, [ isGuest, isEmailLookupCompleted ] ); + // Load payment component when dependencies change useEffect( () => { loadPaymentComponent(); }, [ loadPaymentComponent ] ); + // Conditional rendering based on user state: + // 1. If authenticated: Render the Card component + // 2. If guest with completed email lookup: Render the card fields + // 3. If guest without completed email lookup: Render a message to enter email if ( isGuest ) { if ( isEmailLookupCompleted ) { return
; diff --git a/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButton.js b/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButton.js index b7d55d508..a68de9789 100644 --- a/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButton.js +++ b/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButton.js @@ -1,11 +1,20 @@ import { __ } from '@wordpress/i18n'; +/** + * Renders a button to change the shipping address. + * + * @param {Object} props + * @param {Function} props.onChangeShippingAddressClick - Callback function to handle the click event. + * @return {JSX.Element} The rendered button as an anchor tag. + */ const ShippingChangeButton = ( { onChangeShippingAddressClick } ) => ( { + // Prevent default anchor behavior event.preventDefault(); + // Call the provided click handler onChangeShippingAddressClick(); } } > diff --git a/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButtonManager.js b/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButtonManager.js index e072f0193..abbd7e4da 100644 --- a/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButtonManager.js +++ b/modules/ppcp-axo-block/resources/js/components/Shipping/ShippingChangeButtonManager.js @@ -1,22 +1,32 @@ import { useEffect, createRoot } from '@wordpress/element'; import ShippingChangeButton from './ShippingChangeButton'; +/** + * Manages the insertion and removal of the ShippingChangeButton in the DOM. + * + * @param {Object} props + * @param {Function} props.onChangeShippingAddressClick - Callback function for when the shipping change button is clicked. + * @return {null} This component doesn't render any visible elements directly. + */ const ShippingChangeButtonManager = ( { onChangeShippingAddressClick } ) => { useEffect( () => { const shippingHeading = document.querySelector( '#shipping-fields .wc-block-components-checkout-step__heading' ); + // Check if the shipping heading exists and doesn't already have a change button if ( shippingHeading && ! shippingHeading.querySelector( '.wc-block-checkout-axo-block-card__edit' ) ) { + // Create a new span element to contain the ShippingChangeButton const spanElement = document.createElement( 'span' ); spanElement.className = 'wc-block-checkout-axo-block-card__edit'; shippingHeading.appendChild( spanElement ); + // Create a React root and render the ShippingChangeButton const root = createRoot( spanElement ); root.render( { /> ); + // Cleanup function to remove the button when the component unmounts return () => { root.unmount(); spanElement.remove(); @@ -33,6 +44,7 @@ const ShippingChangeButtonManager = ( { onChangeShippingAddressClick } ) => { } }, [ onChangeShippingAddressClick ] ); + // This component doesn't render anything directly return null; }; diff --git a/modules/ppcp-axo-block/resources/js/components/Shipping/utils.js b/modules/ppcp-axo-block/resources/js/components/Shipping/utils.js index 1bce1ea3f..a131da555 100644 --- a/modules/ppcp-axo-block/resources/js/components/Shipping/utils.js +++ b/modules/ppcp-axo-block/resources/js/components/Shipping/utils.js @@ -1,14 +1,23 @@ import { createRoot } from '@wordpress/element'; import ShippingChangeButtonManager from './ShippingChangeButtonManager'; +/** + * Injects a shipping change button into the DOM if it doesn't already exist. + * + * @param {Function} onChangeShippingAddressClick - Callback function for when the shipping change button is clicked. + */ export const injectShippingChangeButton = ( onChangeShippingAddressClick ) => { + // Check if the button already exists const existingButton = document.querySelector( '#shipping-fields .wc-block-checkout-axo-block-card__edit' ); if ( ! existingButton ) { + // Create a new container for the button const container = document.createElement( 'div' ); document.body.appendChild( container ); + + // Render the ShippingChangeButtonManager in the new container createRoot( container ).render( { } }; +/** + * Removes the shipping change button from the DOM if it exists. + */ export const removeShippingChangeButton = () => { const span = document.querySelector( '#shipping-fields .wc-block-checkout-axo-block-card__edit' diff --git a/modules/ppcp-axo-block/resources/js/components/Watermark/Watermark.js b/modules/ppcp-axo-block/resources/js/components/Watermark/Watermark.js index fcc098768..2ec89de90 100644 --- a/modules/ppcp-axo-block/resources/js/components/Watermark/Watermark.js +++ b/modules/ppcp-axo-block/resources/js/components/Watermark/Watermark.js @@ -1,6 +1,15 @@ import { useEffect, useRef } from '@wordpress/element'; import { log } from '../../../../../ppcp-axo/resources/js/Helper/Debug'; +/** + * Watermark component for displaying AXO watermark. + * + * @param {Object} props + * @param {Object} props.fastlaneSdk - The Fastlane SDK instance. + * @param {string} [props.name='fastlane-watermark-container'] - ID for the watermark container. + * @param {boolean} [props.includeAdditionalInfo=true] - Whether to include additional info in the watermark. + * @return {JSX.Element} The watermark container element. + */ const Watermark = ( { fastlaneSdk, name = 'fastlane-watermark-container', @@ -10,15 +19,19 @@ const Watermark = ( { const watermarkRef = useRef( null ); useEffect( () => { + /** + * Renders the Fastlane watermark. + */ const renderWatermark = async () => { if ( ! containerRef.current ) { return; } - // Clear the container + // Clear the container before rendering containerRef.current.innerHTML = ''; try { + // Create and render the Fastlane watermark const watermark = await fastlaneSdk.FastlaneWatermarkComponent( { includeAdditionalInfo, @@ -34,6 +47,7 @@ const Watermark = ( { renderWatermark(); + // Cleanup function to clear the container on unmount return () => { if ( containerRef.current ) { containerRef.current.innerHTML = ''; @@ -41,6 +55,7 @@ const Watermark = ( { }; }, [ fastlaneSdk, name, includeAdditionalInfo ] ); + // Render the container for the watermark return
; }; diff --git a/modules/ppcp-axo-block/resources/js/components/Watermark/WatermarkManager.js b/modules/ppcp-axo-block/resources/js/components/Watermark/WatermarkManager.js index 41c078585..ef175a98f 100644 --- a/modules/ppcp-axo-block/resources/js/components/Watermark/WatermarkManager.js +++ b/modules/ppcp-axo-block/resources/js/components/Watermark/WatermarkManager.js @@ -7,7 +7,15 @@ import { updateWatermarkContent, } from './utils'; +/** + * Manages the lifecycle and content of the AXO watermark. + * + * @param {Object} props + * @param {Object} props.fastlaneSdk - The Fastlane SDK instance. + * @return {null} This component doesn't render any visible elements. + */ const WatermarkManager = ( { fastlaneSdk } ) => { + // Select relevant states from the AXO store const isGuest = useSelect( ( select ) => select( STORE_NAME ).getIsGuest() ); @@ -20,6 +28,7 @@ const WatermarkManager = ( { fastlaneSdk } ) => { useEffect( () => { if ( isAxoActive || ( ! isAxoActive && ! isAxoScriptLoaded ) ) { + // Create watermark container and update content when AXO is active or loading createWatermarkContainer(); updateWatermarkContent( { isAxoActive, @@ -28,12 +37,15 @@ const WatermarkManager = ( { fastlaneSdk } ) => { isGuest, } ); } else { + // Remove watermark when AXO is inactive and not loading removeWatermark(); } + // Cleanup function to remove watermark on unmount return removeWatermark; }, [ fastlaneSdk, isGuest, isAxoActive, isAxoScriptLoaded ] ); + // This component doesn't render anything directly return null; }; diff --git a/modules/ppcp-axo-block/resources/js/components/Watermark/utils.js b/modules/ppcp-axo-block/resources/js/components/Watermark/utils.js index 38f4a7582..638bffe61 100644 --- a/modules/ppcp-axo-block/resources/js/components/Watermark/utils.js +++ b/modules/ppcp-axo-block/resources/js/components/Watermark/utils.js @@ -1,11 +1,15 @@ import { createElement, createRoot } from '@wordpress/element'; import { Watermark, WatermarkManager } from '../Watermark'; +// Object to store references to the watermark container and root const watermarkReference = { container: null, root: null, }; +/** + * Creates a container for the watermark in the checkout contact information block. + */ export const createWatermarkContainer = () => { const textInputContainer = document.querySelector( '.wp-block-woocommerce-checkout-contact-information-block .wc-block-components-text-input' @@ -16,6 +20,7 @@ export const createWatermarkContainer = () => { textInputContainer.querySelector( 'input[id="email"]' ); if ( emailInput ) { + // Create watermark container watermarkReference.container = document.createElement( 'div' ); watermarkReference.container.setAttribute( 'class', @@ -26,7 +31,7 @@ export const createWatermarkContainer = () => { '.wc-block-axo-email-submit-button-container' ); - // If possible, insert the watermark after the "Continue" button. + // Insert the watermark after the "Continue" button or email input const insertAfterElement = emailButton || emailInput; insertAfterElement.parentNode.insertBefore( @@ -34,6 +39,7 @@ export const createWatermarkContainer = () => { insertAfterElement.nextSibling ); + // Create a root for the watermark watermarkReference.root = createRoot( watermarkReference.container ); @@ -41,12 +47,19 @@ export const createWatermarkContainer = () => { } }; +/** + * Sets up the watermark manager component. + * + * @param {Object} fastlaneSdk - The Fastlane SDK instance. + * @return {Function} Cleanup function to remove the watermark. + */ export const setupWatermark = ( fastlaneSdk ) => { const container = document.createElement( 'div' ); document.body.appendChild( container ); const root = createRoot( container ); root.render( createElement( WatermarkManager, { fastlaneSdk } ) ); + // Return cleanup function return () => { root.unmount(); if ( container && container.parentNode ) { @@ -55,6 +68,9 @@ export const setupWatermark = ( fastlaneSdk ) => { }; }; +/** + * Removes the watermark from the DOM and resets the reference. + */ export const removeWatermark = () => { if ( watermarkReference.root ) { watermarkReference.root.unmount(); @@ -65,6 +81,7 @@ export const removeWatermark = () => { watermarkReference.container ); } else { + // Fallback removal if parent node is not available const detachedContainer = document.querySelector( '.wc-block-checkout-axo-block-watermark-container' ); @@ -73,15 +90,30 @@ export const removeWatermark = () => { } } } + // Reset watermark reference Object.assign( watermarkReference, { container: null, root: null } ); }; +/** + * Renders content in the watermark container. + * + * @param {ReactElement} content - The content to render. + */ export const renderWatermarkContent = ( content ) => { if ( watermarkReference.root ) { watermarkReference.root.render( content ); } }; +/** + * Updates the watermark content based on the current state. + * + * @param {Object} params - State parameters. + * @param {boolean} params.isAxoActive - Whether AXO is active. + * @param {boolean} params.isAxoScriptLoaded - Whether AXO script is loaded. + * @param {Object} params.fastlaneSdk - The Fastlane SDK instance. + * @param {boolean} params.isGuest - Whether the user is a guest. + */ export const updateWatermarkContent = ( { isAxoActive, isAxoScriptLoaded, @@ -89,6 +121,7 @@ export const updateWatermarkContent = ( { isGuest, } ) => { if ( ! isAxoActive && ! isAxoScriptLoaded ) { + // Show loading spinner renderWatermarkContent( createElement( 'span', { className: 'wc-block-components-spinner', @@ -96,6 +129,7 @@ export const updateWatermarkContent = ( { } ) ); } else if ( isAxoActive ) { + // Show Fastlane watermark renderWatermarkContent( createElement( Watermark, { fastlaneSdk, @@ -104,6 +138,7 @@ export const updateWatermarkContent = ( { } ) ); } else { + // Clear watermark content renderWatermarkContent( null ); } }; diff --git a/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js b/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js index 85efa0234..563f9510d 100644 --- a/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js +++ b/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js @@ -4,6 +4,21 @@ import { injectShippingChangeButton } from '../components/Shipping'; import { injectCardChangeButton } from '../components/Card'; import { setIsGuest, setIsEmailLookupCompleted } from '../stores/axoStore'; +/** + * Creates an email lookup handler function for AXO checkout. + * + * @param {Object} fastlaneSdk - The Fastlane SDK instance. + * @param {Function} setShippingAddress - Function to set shipping address in the store. + * @param {Function} setCardDetails - Function to set card details in the store. + * @param {Function} snapshotFields - Function to save current field values. + * @param {Object} wooShippingAddress - Current WooCommerce shipping address. + * @param {Object} wooBillingAddress - Current WooCommerce billing address. + * @param {Function} setWooShippingAddress - Function to update WooCommerce shipping address. + * @param {Function} setWooBillingAddress - Function to update WooCommerce billing address. + * @param {Function} onChangeShippingAddressClick - Handler for shipping address change. + * @param {Function} onChangeCardButtonClick - Handler for card change. + * @return {Function} The email lookup handler function. + */ export const createEmailLookupHandler = ( fastlaneSdk, setShippingAddress, @@ -20,6 +35,7 @@ export const createEmailLookupHandler = ( try { log( `Email value being looked up: ${ email }` ); + // Validate Fastlane SDK initialization if ( ! fastlaneSdk ) { throw new Error( 'FastlaneSDK is not initialized' ); } @@ -30,12 +46,13 @@ export const createEmailLookupHandler = ( ); } + // Perform email lookup const lookup = await fastlaneSdk.identity.lookupCustomerByEmail( email ); log( `Lookup response: ${ JSON.stringify( lookup ) }` ); - // Gary flow + // Handle Gary flow (new user) if ( lookup && lookup.customerContextId === '' ) { setIsEmailLookupCompleted( true ); } @@ -45,6 +62,7 @@ export const createEmailLookupHandler = ( return; } + // Trigger authentication flow const authResponse = await fastlaneSdk.identity.triggerAuthenticationFlow( lookup.customerContextId @@ -56,15 +74,18 @@ export const createEmailLookupHandler = ( const { authenticationState, profileData } = authResponse; - // OTP success/fail/cancel flow + // Mark email lookup as completed for OTP flow if ( authResponse ) { setIsEmailLookupCompleted( true ); } + // Handle successful authentication if ( authenticationState === 'succeeded' ) { + // Save current field values snapshotFields( wooShippingAddress, wooBillingAddress ); setIsGuest( false ); + // Update store with profile data if ( profileData && profileData.shippingAddress ) { setShippingAddress( profileData.shippingAddress ); } @@ -74,12 +95,14 @@ export const createEmailLookupHandler = ( log( `Profile Data: ${ JSON.stringify( profileData ) }` ); + // Populate WooCommerce fields with profile data populateWooFields( profileData, setWooShippingAddress, setWooBillingAddress ); + // Inject change buttons for shipping and card injectShippingChangeButton( onChangeShippingAddressClick ); injectCardChangeButton( onChangeCardButtonClick ); } else { diff --git a/modules/ppcp-axo-block/resources/js/helpers/classnamesManager.js b/modules/ppcp-axo-block/resources/js/helpers/classnamesManager.js index 2af96aea9..7b25cec31 100644 --- a/modules/ppcp-axo-block/resources/js/helpers/classnamesManager.js +++ b/modules/ppcp-axo-block/resources/js/helpers/classnamesManager.js @@ -4,6 +4,8 @@ import { STORE_NAME } from '../stores/axoStore'; /** * Sets up a class toggle based on the isGuest state for the express payment block. + * This hides the express payment methods if the user is authenticated (Ryan flow). + * * @return {Function} Unsubscribe function for cleanup. */ export const setupAuthenticationClassToggle = () => { @@ -41,6 +43,13 @@ export const setupAuthenticationClassToggle = () => { return unsubscribe; }; +/** + * Sets up a class toggle based on the isEmailLookupCompleted state for the checkout fields block. + * This hides the Shipping Address fields, Billing Address fields, Shipping Options section, + * Order Notes section, Checkout Terms section, and Place Order button until email lookup is completed. + * + * @return {Function} Unsubscribe function for cleanup. + */ export const setupEmailLookupCompletedClassToggle = () => { const targetSelector = '.wp-block-woocommerce-checkout-fields-block'; const emailLookupCompletedClass = 'wc-block-axo-email-lookup-completed'; @@ -77,7 +86,7 @@ export const setupEmailLookupCompletedClassToggle = () => { }; /** - * Sets up class toggles for the contact information block based on isAxoActive and isGuest states. + * Sets up class toggles for the contact information block based on isAxoActive, isGuest, and isEmailLookupCompleted states. * @return {Function} Unsubscribe function for cleanup. */ export const setupCheckoutBlockClassToggles = () => { @@ -133,7 +142,7 @@ export const setupCheckoutBlockClassToggles = () => { /** * Initializes all class toggles. - * @return {Function} Cleanup function. + * @return {Function} Cleanup function to unsubscribe all listeners. */ export const initializeClassToggles = () => { const unsubscribeAuth = setupAuthenticationClassToggle(); @@ -141,6 +150,7 @@ export const initializeClassToggles = () => { setupEmailLookupCompletedClassToggle(); const unsubscribeContactInfo = setupCheckoutBlockClassToggles(); + // Return a cleanup function that unsubscribes all listeners return () => { if ( unsubscribeAuth ) { unsubscribeAuth(); diff --git a/modules/ppcp-axo-block/resources/js/helpers/fieldHelpers.js b/modules/ppcp-axo-block/resources/js/helpers/fieldHelpers.js index 9c66d762d..c04b62a24 100644 --- a/modules/ppcp-axo-block/resources/js/helpers/fieldHelpers.js +++ b/modules/ppcp-axo-block/resources/js/helpers/fieldHelpers.js @@ -1,6 +1,12 @@ import { dispatch } from '@wordpress/data'; import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; +/** + * Saves the current shipping and billing address to localStorage. + * + * @param {Object} shippingAddress - The current shipping address. + * @param {Object} billingAddress - The current billing address. + */ export const snapshotFields = ( shippingAddress, billingAddress ) => { if ( ! shippingAddress || ! billingAddress ) { log( @@ -15,6 +21,7 @@ export const snapshotFields = ( shippingAddress, billingAddress ) => { const originalData = { shippingAddress, billingAddress }; log( `Snapshot data: ${ JSON.stringify( originalData ) }` ); try { + // Save the original data to localStorage localStorage.setItem( 'axoOriginalCheckoutFields', JSON.stringify( originalData ) @@ -24,6 +31,12 @@ export const snapshotFields = ( shippingAddress, billingAddress ) => { } }; +/** + * Restores the original shipping and billing addresses from localStorage. + * + * @param {Function} updateShippingAddress - Function to update the shipping address. + * @param {Function} updateBillingAddress - Function to update the billing address. + */ export const restoreOriginalFields = ( updateShippingAddress, updateBillingAddress @@ -31,6 +44,7 @@ export const restoreOriginalFields = ( log( 'Attempting to restore original fields' ); let savedData; try { + // Retrieve saved data from localStorage savedData = localStorage.getItem( 'axoOriginalCheckoutFields' ); log( `Data retrieved from localStorage: ${ JSON.stringify( savedData ) }` @@ -42,11 +56,13 @@ export const restoreOriginalFields = ( if ( savedData ) { try { const parsedData = JSON.parse( savedData ); + // Restore shipping address if available if ( parsedData.shippingAddress ) { updateShippingAddress( parsedData.shippingAddress ); } else { log( `No shipping address found in saved data`, 'warn' ); } + // Restore billing address if available if ( parsedData.billingAddress ) { log( `Restoring billing address: @@ -67,6 +83,13 @@ export const restoreOriginalFields = ( } }; +/** + * Populates WooCommerce fields with profile data from AXO. + * + * @param {Object} profileData - The profile data from AXO. + * @param {Function} setWooShippingAddress - Function to set WooCommerce shipping address. + * @param {Function} setWooBillingAddress - Function to set WooCommerce billing address. + */ export const populateWooFields = ( profileData, setWooShippingAddress, @@ -82,14 +105,14 @@ export const populateWooFields = ( const checkoutDispatch = dispatch( CHECKOUT_STORE_KEY ); - // Uncheck the 'Use same address for billing' checkbox if the method exists. + // Uncheck the 'Use same address for billing' checkbox if the method exists if ( typeof checkoutDispatch.__internalSetUseShippingAsBilling === 'function' ) { checkoutDispatch.__internalSetUseShippingAsBilling( false ); } - // Save shipping address. + // Prepare and set shipping address const { address, name, phoneNumber } = profileData.shippingAddress; const shippingAddress = { @@ -111,7 +134,7 @@ export const populateWooFields = ( ); setWooShippingAddress( shippingAddress ); - // Save billing address. + // Prepare and set billing address const billingData = profileData.card.paymentSource.card.billingAddress; const billingAddress = { @@ -132,12 +155,12 @@ export const populateWooFields = ( ); setWooBillingAddress( billingAddress ); - // Collapse shipping address input fields into the card view. + // Collapse shipping address input fields into the card view if ( typeof checkoutDispatch.setEditingShippingAddress === 'function' ) { checkoutDispatch.setEditingShippingAddress( false ); } - // Collapse billing address input fields into the card view. + // Collapse billing address input fields into the card view if ( typeof checkoutDispatch.setEditingBillingAddress === 'function' ) { checkoutDispatch.setEditingBillingAddress( false ); } diff --git a/modules/ppcp-axo-block/resources/js/hooks/useAddressEditing.js b/modules/ppcp-axo-block/resources/js/hooks/useAddressEditing.js index 59fc32769..f1e57c943 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useAddressEditing.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useAddressEditing.js @@ -3,11 +3,21 @@ import { useDispatch, useSelect } from '@wordpress/data'; const CHECKOUT_STORE_KEY = 'wc/store/checkout'; +/** + * Custom hook to manage address editing states in the checkout process. + * + * When set to true (default), the shipping and billing address forms are displayed. + * When set to false, the address forms are hidden and the user can only view the address details (card view). + * + * @return {Object} An object containing address editing states and setter functions. + */ export const useAddressEditing = () => { + // Select address editing states from the checkout store const { isEditingShippingAddress, isEditingBillingAddress } = useSelect( ( select ) => { const store = select( CHECKOUT_STORE_KEY ); return { + // Default to true if the getter function doesn't exist isEditingShippingAddress: store.getEditingShippingAddress ? store.getEditingShippingAddress() : true, @@ -19,9 +29,11 @@ export const useAddressEditing = () => { [] ); + // Get dispatch functions to update address editing states const { setEditingShippingAddress, setEditingBillingAddress } = useDispatch( CHECKOUT_STORE_KEY ); + // Memoized function to update shipping address editing state const setShippingAddressEditing = useCallback( ( isEditing ) => { if ( typeof setEditingShippingAddress === 'function' ) { @@ -31,6 +43,7 @@ export const useAddressEditing = () => { [ setEditingShippingAddress ] ); + // Memoized function to update billing address editing state const setBillingAddressEditing = useCallback( ( isEditing ) => { if ( typeof setEditingBillingAddress === 'function' ) { @@ -40,6 +53,7 @@ export const useAddressEditing = () => { [ setEditingBillingAddress ] ); + // Return an object with address editing states and setter functions return { isEditingShippingAddress, isEditingBillingAddress, diff --git a/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js b/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js index 23175e31f..871197722 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js @@ -12,13 +12,21 @@ import { import { restoreOriginalFields } from '../helpers/fieldHelpers'; import useCustomerData from './useCustomerData'; +/** + * Custom hook to handle cleanup of AXO functionality. + * This hook ensures that all AXO-related changes are reverted when the component unmounts (a different payment method gets selected). + */ const useAxoCleanup = () => { + // Get dispatch functions from the AXO store const { setIsAxoActive, setIsGuest } = useDispatch( STORE_NAME ); + + // Get functions to update WooCommerce shipping and billing addresses const { setShippingAddress: updateWooShippingAddress, setBillingAddress: updateWooBillingAddress, } = useCustomerData(); + // Effect to restore original WooCommerce fields on unmount useEffect( () => { return () => { log( 'Cleaning up: Restoring WooCommerce fields' ); @@ -29,14 +37,21 @@ const useAxoCleanup = () => { }; }, [ updateWooShippingAddress, updateWooBillingAddress ] ); + // Effect to clean up AXO-specific functionality on unmount useEffect( () => { return () => { log( 'Cleaning up Axo component' ); + + // Reset AXO state setIsAxoActive( false ); setIsGuest( true ); + + // Remove AXO UI elements removeShippingChangeButton(); removeCardChangeButton(); removeWatermark(); + + // Remove email functionality if it was set up if ( isEmailFunctionalitySetup() ) { log( 'Removing email functionality' ); removeEmailFunctionality(); diff --git a/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js b/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js index bdc615461..af1aaca38 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js @@ -12,20 +12,46 @@ import useCustomerData from './useCustomerData'; import useShippingAddressChange from './useShippingAddressChange'; import useCardChange from './useCardChange'; -const useAxoSetup = ( ppcpConfig, fastlaneSdk, paymentComponent ) => { +/** + * Custom hook to set up AXO functionality. + * + * @param {string} namespace - Namespace for the PayPal script. + * @param {Object} ppcpConfig - PayPal Checkout configuration. + * @param {boolean} isConfigLoaded - Whether the PayPal config has loaded. + * @param {Object} fastlaneSdk - Fastlane SDK instance. + * @param {Object} paymentComponent - Payment component instance. + * @return {boolean} Whether PayPal script has loaded. + */ +const useAxoSetup = ( + namespace, + ppcpConfig, + isConfigLoaded, + fastlaneSdk, + paymentComponent +) => { + // Get dispatch functions from the AXO store const { setIsAxoActive, setIsAxoScriptLoaded, setShippingAddress, setCardDetails, } = useDispatch( STORE_NAME ); - const paypalLoaded = usePayPalScript( ppcpConfig ); + + // Check if PayPal script has loaded + const paypalLoaded = usePayPalScript( + namespace, + ppcpConfig, + isConfigLoaded + ); + + // Set up card and shipping address change handlers const onChangeCardButtonClick = useCardChange( fastlaneSdk ); const onChangeShippingAddressClick = useShippingAddressChange( fastlaneSdk, setShippingAddress ); + // Get customer data and setter functions const { shippingAddress: wooShippingAddress, billingAddress: wooBillingAddress, @@ -33,17 +59,22 @@ const useAxoSetup = ( ppcpConfig, fastlaneSdk, paymentComponent ) => { setBillingAddress: setWooBillingAddress, } = useCustomerData(); + // Set up phone sync handler usePhoneSyncHandler( paymentComponent ); + // Initialize class toggles on mount useEffect( () => { initializeClassToggles(); }, [] ); + // Set up AXO functionality when PayPal and Fastlane are loaded useEffect( () => { setupWatermark( fastlaneSdk ); if ( paypalLoaded && fastlaneSdk ) { setIsAxoScriptLoaded( true ); setIsAxoActive( true ); + + // Create and set up email lookup handler const emailLookupHandler = createEmailLookupHandler( fastlaneSdk, setShippingAddress, diff --git a/modules/ppcp-axo-block/resources/js/hooks/useCardChange.js b/modules/ppcp-axo-block/resources/js/hooks/useCardChange.js index 6d2482416..29ab2f42c 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useCardChange.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useCardChange.js @@ -5,22 +5,29 @@ import { useAddressEditing } from './useAddressEditing'; import useCustomerData from './useCustomerData'; import { STORE_NAME } from '../stores/axoStore'; +/** + * Custom hook to handle the 'Choose a different card' selection. + * + * @param {Object} fastlaneSdk - The Fastlane SDK instance. + * @return {Function} Callback function to trigger card selection and update related data. + */ export const useCardChange = ( fastlaneSdk ) => { const { setBillingAddressEditing } = useAddressEditing(); const { setBillingAddress: setWooBillingAddress } = useCustomerData(); - const { setCardDetails, setShippingAddress } = useDispatch( STORE_NAME ); + const { setCardDetails } = useDispatch( STORE_NAME ); return useCallback( async () => { if ( fastlaneSdk ) { + // Show card selector and get the user's selection const { selectionChanged, selectedCard } = await fastlaneSdk.profile.showCardSelector(); if ( selectionChanged && selectedCard?.paymentSource?.card ) { - // Use the fallback logic for cardholder's name. + // Extract cardholder and billing information from the selected card const { name, billingAddress } = selectedCard.paymentSource.card; - // If name is missing, use billing details as a fallback for the name. + // Parse cardholder's name, using billing details as a fallback if missing let firstName = ''; let lastName = ''; @@ -30,6 +37,7 @@ export const useCardChange = ( fastlaneSdk ) => { lastName = nameParts.slice( 1 ).join( ' ' ); } + // Transform the billing address into WooCommerce format const newBillingAddress = { first_name: firstName, last_name: lastName, @@ -41,20 +49,19 @@ export const useCardChange = ( fastlaneSdk ) => { country: billingAddress?.countryCode || '', }; - // Batch state updates. + // Batch update states await Promise.all( [ + // Update the selected card details in the custom store new Promise( ( resolve ) => { setCardDetails( selectedCard ); resolve(); } ), + // Update the WooCommerce billing address in the WooCommerce store new Promise( ( resolve ) => { setWooBillingAddress( newBillingAddress ); resolve(); } ), - new Promise( ( resolve ) => { - setShippingAddress( newBillingAddress ); - resolve(); - } ), + // Trigger the Address Card view by setting the billing address editing state to false new Promise( ( resolve ) => { setBillingAddressEditing( false ); resolve(); @@ -68,7 +75,6 @@ export const useCardChange = ( fastlaneSdk ) => { fastlaneSdk, setCardDetails, setWooBillingAddress, - setShippingAddress, setBillingAddressEditing, ] ); }; diff --git a/modules/ppcp-axo-block/resources/js/hooks/useCustomerData.js b/modules/ppcp-axo-block/resources/js/hooks/useCustomerData.js index 6f7ffe537..b839e3199 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useCustomerData.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useCustomerData.js @@ -1,16 +1,24 @@ import { useCallback, useMemo } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; +/** + * Custom hook to manage customer data in the WooCommerce store. + * + * @return {Object} An object containing customer addresses and setter functions. + */ export const useCustomerData = () => { + // Fetch customer data from the WooCommerce store const customerData = useSelect( ( select ) => select( 'wc/store/cart' ).getCustomerData() ); + // Get dispatch functions to update shipping and billing addresses const { setShippingAddress: setShippingAddressDispatch, setBillingAddress: setBillingAddressDispatch, } = useDispatch( 'wc/store/cart' ); + // Memoized function to update shipping address const setShippingAddress = useCallback( ( address ) => { setShippingAddressDispatch( address ); @@ -18,6 +26,7 @@ export const useCustomerData = () => { [ setShippingAddressDispatch ] ); + // Memoized function to update billing address const setBillingAddress = useCallback( ( address ) => { setBillingAddressDispatch( address ); @@ -25,6 +34,7 @@ export const useCustomerData = () => { [ setBillingAddressDispatch ] ); + // Return memoized object with customer data and setter functions return useMemo( () => ( { shippingAddress: customerData.shippingAddress, diff --git a/modules/ppcp-axo-block/resources/js/hooks/useDeleteEmptyKeys.js b/modules/ppcp-axo-block/resources/js/hooks/useDeleteEmptyKeys.js index 04043e0a9..63eaffe14 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useDeleteEmptyKeys.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useDeleteEmptyKeys.js @@ -3,17 +3,30 @@ import { useCallback } from '@wordpress/element'; const isObject = ( value ) => typeof value === 'object' && value !== null; const isNonEmptyString = ( value ) => value !== ''; +/** + * Recursively removes empty values from an object. + * Empty values are considered to be: + * - Empty strings + * - Empty objects + * - Null or undefined values + * + * @param {Object} obj - The object to clean. + * @return {Object} A new object with empty values removed. + */ const removeEmptyValues = ( obj ) => { + // If not an object, return the value as is if ( ! isObject( obj ) ) { return obj; } return Object.fromEntries( Object.entries( obj ) + // Recursively apply removeEmptyValues to nested objects .map( ( [ key, value ] ) => [ key, isObject( value ) ? removeEmptyValues( value ) : value, ] ) + // Filter out empty values .filter( ( [ _, value ] ) => isObject( value ) ? Object.keys( value ).length > 0 @@ -22,6 +35,11 @@ const removeEmptyValues = ( obj ) => { ); }; +/** + * Custom hook that returns a memoized function to remove empty values from an object. + * + * @return {Function} A memoized function that removes empty values from an object. + */ export const useDeleteEmptyKeys = () => { return useCallback( removeEmptyValues, [] ); }; diff --git a/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js b/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js index 9bd9db8d5..7e68ccab1 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js @@ -1,21 +1,39 @@ import { useEffect, useRef, useState, useMemo } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; import Fastlane from '../../../../ppcp-axo/resources/js/Connection/Fastlane'; import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; import { useDeleteEmptyKeys } from './useDeleteEmptyKeys'; +import { STORE_NAME } from '../stores/axoStore'; -const useFastlaneSdk = ( axoConfig, ppcpConfig ) => { +/** + * Custom hook to initialize and manage the Fastlane SDK. + * + * @param {string} namespace - Namespace for the PayPal script. + * @param {Object} axoConfig - Configuration for AXO. + * @param {Object} ppcpConfig - Configuration for PPCP. + * @return {Object|null} The initialized Fastlane SDK instance or null. + */ +const useFastlaneSdk = ( namespace, axoConfig, ppcpConfig ) => { const [ fastlaneSdk, setFastlaneSdk ] = useState( null ); const initializingRef = useRef( false ); const configRef = useRef( { axoConfig, ppcpConfig } ); const deleteEmptyKeys = useDeleteEmptyKeys(); + const { isPayPalLoaded } = useSelect( + ( select ) => ( { + isPayPalLoaded: select( STORE_NAME ).getIsPayPalLoaded(), + } ), + [] + ); + const styleOptions = useMemo( () => { return deleteEmptyKeys( configRef.current.axoConfig.style_options ); }, [ deleteEmptyKeys ] ); + // Effect to initialize Fastlane SDK useEffect( () => { const initFastlane = async () => { - if ( initializingRef.current || fastlaneSdk ) { + if ( initializingRef.current || fastlaneSdk || ! isPayPalLoaded ) { return; } @@ -23,17 +41,20 @@ const useFastlaneSdk = ( axoConfig, ppcpConfig ) => { log( 'Init Fastlane' ); try { - const fastlane = new Fastlane(); + const fastlane = new Fastlane( namespace ); + // Set sandbox environment if configured if ( configRef.current.axoConfig.environment.is_sandbox ) { window.localStorage.setItem( 'axoEnv', 'sandbox' ); } + // Connect to Fastlane with locale and style options await fastlane.connect( { locale: configRef.current.ppcpConfig.locale, styles: styleOptions, } ); + // Set locale (hardcoded to 'en_us' for now) fastlane.setLocale( 'en_us' ); setFastlaneSdk( fastlane ); @@ -45,8 +66,9 @@ const useFastlaneSdk = ( axoConfig, ppcpConfig ) => { }; initFastlane(); - }, [ fastlaneSdk, styleOptions ] ); + }, [ fastlaneSdk, styleOptions, isPayPalLoaded, namespace ] ); + // Effect to update the config ref when configs change useEffect( () => { configRef.current = { axoConfig, ppcpConfig }; }, [ axoConfig, ppcpConfig ] ); diff --git a/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js b/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js index ca74c887b..86975f78c 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useHandlePaymentSetup.js @@ -2,29 +2,40 @@ import { useCallback } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; import { STORE_NAME } from '../stores/axoStore'; +/** + * Custom hook to handle payment setup in the checkout process. + * + * @param {Object} emitResponse - Object containing response types. + * @param {Object} paymentComponent - The payment component instance. + * @param {Object} tokenizedCustomerData - Tokenized customer data for payment. + * @return {Function} Callback function to handle payment setup. + */ const useHandlePaymentSetup = ( emitResponse, paymentComponent, tokenizedCustomerData ) => { + // Select card details from the store const { cardDetails } = useSelect( ( select ) => ( { - shippingAddress: select( STORE_NAME ).getShippingAddress(), cardDetails: select( STORE_NAME ).getCardDetails(), } ), [] ); return useCallback( async () => { + // Determine if it's a Ryan flow (saved card) based on the presence of card ID const isRyanFlow = !! cardDetails?.id; let cardToken = cardDetails?.id; + // If no card token and payment component exists, get a new token if ( ! cardToken && paymentComponent ) { cardToken = await paymentComponent .getPaymentToken( tokenizedCustomerData ) .then( ( response ) => response.id ); } + // Handle error cases when card token is not available if ( ! cardToken ) { let reason = 'tokenization error'; diff --git a/modules/ppcp-axo-block/resources/js/hooks/usePayPalCommerceGateway.js b/modules/ppcp-axo-block/resources/js/hooks/usePayPalCommerceGateway.js new file mode 100644 index 000000000..5205636e3 --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/hooks/usePayPalCommerceGateway.js @@ -0,0 +1,46 @@ +import { useState, useEffect } from '@wordpress/element'; +import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; + +/** + * Custom hook to load and manage the PayPal Commerce Gateway configuration. + * + * @param {Object} initialConfig - Initial configuration object. + * @return {Object} An object containing the loaded config and a boolean indicating if it's loaded. + */ +const usePayPalCommerceGateway = ( initialConfig ) => { + const [ isConfigLoaded, setIsConfigLoaded ] = useState( false ); + const [ ppcpConfig, setPpcpConfig ] = useState( initialConfig ); + + useEffect( () => { + /** + * Function to load the PayPal Commerce Gateway configuration. + */ + const loadConfig = () => { + if ( typeof window.PayPalCommerceGateway !== 'undefined' ) { + setPpcpConfig( window.PayPalCommerceGateway ); + setIsConfigLoaded( true ); + } else { + log( 'PayPal Commerce Gateway config not loaded.', 'error' ); + } + }; + + // Check if the DOM is still loading + if ( document.readyState === 'loading' ) { + // If it's loading, add an event listener for when the DOM is fully loaded + document.addEventListener( 'DOMContentLoaded', loadConfig ); + } else { + // If it's already loaded, call the loadConfig function immediately + loadConfig(); + } + + // Cleanup function to remove the event listener + return () => { + document.removeEventListener( 'DOMContentLoaded', loadConfig ); + }; + }, [] ); + + // Return the loaded configuration and the loading status + return { isConfigLoaded, ppcpConfig }; +}; + +export default usePayPalCommerceGateway; diff --git a/modules/ppcp-axo-block/resources/js/hooks/usePayPalScript.js b/modules/ppcp-axo-block/resources/js/hooks/usePayPalScript.js index 8fc85829e..1d536344f 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/usePayPalScript.js +++ b/modules/ppcp-axo-block/resources/js/hooks/usePayPalScript.js @@ -1,21 +1,48 @@ -import { useState, useEffect } from '@wordpress/element'; +import { useEffect } from '@wordpress/element'; +import { useDispatch, useSelect } from '@wordpress/data'; import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; -import { loadPaypalScript } from '../../../../ppcp-button/resources/js/modules/Helper/ScriptLoading'; +import { loadPayPalScript } from '../../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading'; +import { STORE_NAME } from '../stores/axoStore'; -const usePayPalScript = ( ppcpConfig ) => { - const [ isLoaded, setIsLoaded ] = useState( false ); +/** + * Custom hook to load the PayPal script. + * + * @param {string} namespace - Namespace for the PayPal script. + * @param {Object} ppcpConfig - Configuration object for PayPal script. + * @param {boolean} isConfigLoaded - Whether the PayPal Commerce Gateway config is loaded. + * @return {boolean} True if the PayPal script has loaded, false otherwise. + */ +const usePayPalScript = ( namespace, ppcpConfig, isConfigLoaded ) => { + // Get dispatch functions from the AXO store + const { setIsPayPalLoaded } = useDispatch( STORE_NAME ); + + // Select relevant states from the AXO store + const { isPayPalLoaded } = useSelect( + ( select ) => ( { + isPayPalLoaded: select( STORE_NAME ).getIsPayPalLoaded(), + } ), + [] + ); useEffect( () => { - if ( ! isLoaded ) { - log( 'Loading PayPal script' ); - loadPaypalScript( ppcpConfig, () => { - log( 'PayPal script loaded' ); - setIsLoaded( true ); - } ); - } - }, [ ppcpConfig, isLoaded ] ); + const loadScript = async () => { + if ( ! isPayPalLoaded && isConfigLoaded ) { + try { + await loadPayPalScript( namespace, ppcpConfig ); + setIsPayPalLoaded( true ); + } catch ( error ) { + log( + `Error loading PayPal script for namespace: ${ namespace }. Error: ${ error }`, + 'error' + ); + } + } + }; - return isLoaded; + loadScript(); + }, [ ppcpConfig, isConfigLoaded, isPayPalLoaded ] ); + + return isPayPalLoaded; }; export default usePayPalScript; diff --git a/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js b/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js index 087fc807e..8e82b5011 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js +++ b/modules/ppcp-axo-block/resources/js/hooks/usePaymentSetupEffect.js @@ -1,5 +1,13 @@ import { useEffect, useCallback } from '@wordpress/element'; +/** + * Custom hook to handle payment setup effects in the checkout flow. + * + * @param {Function} onPaymentSetup - Function to subscribe to payment setup events. + * @param {Function} handlePaymentSetup - Callback to process payment setup. + * @param {Function} setPaymentComponent - Function to update the payment component state. + * @return {Object} Object containing the handlePaymentLoad function. + */ const usePaymentSetupEffect = ( onPaymentSetup, handlePaymentSetup, @@ -17,6 +25,11 @@ const usePaymentSetupEffect = ( }; }, [ onPaymentSetup, handlePaymentSetup ] ); + /** + * Callback function to handle payment component loading. + * + * @param {Object} component - The loaded payment component. + */ const handlePaymentLoad = useCallback( ( component ) => { setPaymentComponent( component ); diff --git a/modules/ppcp-axo-block/resources/js/hooks/useShippingAddressChange.js b/modules/ppcp-axo-block/resources/js/hooks/useShippingAddressChange.js index 6d8e7b4a2..2c45b8930 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useShippingAddressChange.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useShippingAddressChange.js @@ -2,19 +2,30 @@ import { useCallback } from '@wordpress/element'; import { useAddressEditing } from './useAddressEditing'; import useCustomerData from './useCustomerData'; +/** + * Custom hook to handle the 'Choose a different shipping address' selection. + * + * @param {Object} fastlaneSdk - The Fastlane SDK instance. + * @param {Function} setShippingAddress - Function to update the shipping address state. + * @return {Function} Callback function to trigger shipping address selection and update. + */ export const useShippingAddressChange = ( fastlaneSdk, setShippingAddress ) => { const { setShippingAddressEditing } = useAddressEditing(); const { setShippingAddress: setWooShippingAddress } = useCustomerData(); return useCallback( async () => { if ( fastlaneSdk ) { + // Show shipping address selector and get the user's selection const { selectionChanged, selectedAddress } = await fastlaneSdk.profile.showShippingAddressSelector(); + if ( selectionChanged ) { + // Update the shipping address in the custom store with the selected address setShippingAddress( selectedAddress ); const { address, name, phoneNumber } = selectedAddress; + // Transform the selected address into WooCommerce format const newShippingAddress = { first_name: name.firstName, last_name: name.lastName, @@ -27,11 +38,13 @@ export const useShippingAddressChange = ( fastlaneSdk, setShippingAddress ) => { phone: phoneNumber.nationalNumber, }; + // Update the WooCommerce shipping address in the WooCommerce store await new Promise( ( resolve ) => { setWooShippingAddress( newShippingAddress ); resolve(); } ); + // Trigger the Address Card view by setting the shipping address editing state to false await new Promise( ( resolve ) => { setShippingAddressEditing( false ); resolve(); diff --git a/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js b/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js index 868ddcb85..c0a7dcf97 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useTokenizeCustomerData.js @@ -1,18 +1,27 @@ import { useMemo } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; +import useCustomerData from './useCustomerData'; +/** + * Custom hook to prepare customer data for tokenization. + * + * @return {Object} Formatted customer data for tokenization. + */ export const useTokenizeCustomerData = () => { - const customerData = useSelect( ( select ) => - select( 'wc/store/cart' ).getCustomerData() - ); + const { billingAddress, shippingAddress } = useCustomerData(); + /** + * Validates if an address contains the minimum required data. + * + * @param {Object} address - The address object to validate. + * @return {boolean} True if the address is valid, false otherwise. + */ const isValidAddress = ( address ) => { - // At least one name must be present. + // At least one name must be present if ( ! address.first_name && ! address.last_name ) { return false; } - // Street, city, postcode, country are mandatory; state is optional. + // Street, city, postcode, country are mandatory; state is optional return ( address.address_1 && address.city && @@ -21,15 +30,14 @@ export const useTokenizeCustomerData = () => { ); }; - // Memoize the customer data to avoid unnecessary re-renders (and potential infinite loops). + // Memoize the customer data to avoid unnecessary re-renders (and potential infinite loops) return useMemo( () => { - const { billingAddress, shippingAddress } = customerData; - - // Prefer billing address, but fallback to shipping address if billing address is not valid. + // Determine the main address, preferring billing address if valid const mainAddress = isValidAddress( billingAddress ) ? billingAddress : shippingAddress; + // Format the customer data for tokenization return { cardholderName: { fullName: `${ mainAddress.first_name } ${ mainAddress.last_name }`, @@ -43,7 +51,7 @@ export const useTokenizeCustomerData = () => { countryCode: mainAddress.country, }, }; - }, [ customerData ] ); + }, [ billingAddress, shippingAddress ] ); }; export default useTokenizeCustomerData; diff --git a/modules/ppcp-axo-block/resources/js/index.js b/modules/ppcp-axo-block/resources/js/index.js index 9ee3b1112..a45473e50 100644 --- a/modules/ppcp-axo-block/resources/js/index.js +++ b/modules/ppcp-axo-block/resources/js/index.js @@ -9,25 +9,25 @@ import useAxoSetup from './hooks/useAxoSetup'; import useAxoCleanup from './hooks/useAxoCleanup'; import useHandlePaymentSetup from './hooks/useHandlePaymentSetup'; import usePaymentSetupEffect from './hooks/usePaymentSetupEffect'; +import usePayPalCommerceGateway from './hooks/usePayPalCommerceGateway'; // Components import { Payment } from './components/Payment/Payment'; const gatewayHandle = 'ppcp-axo-gateway'; -const ppcpConfig = wc.wcSettings.getSetting( `${ gatewayHandle }_data` ); - -if ( typeof window.PayPalCommerceGateway === 'undefined' ) { - window.PayPalCommerceGateway = ppcpConfig; -} - -const axoConfig = window.wc_ppcp_axo; - +const namespace = 'ppcpBlocksPaypalAxo'; +const initialConfig = wc.wcSettings.getSetting( `${ gatewayHandle }_data` ); const Axo = ( props ) => { const { eventRegistration, emitResponse } = props; const { onPaymentSetup } = eventRegistration; const [ paymentComponent, setPaymentComponent ] = useState( null ); - const fastlaneSdk = useFastlaneSdk( axoConfig, ppcpConfig ); + const { isConfigLoaded, ppcpConfig } = + usePayPalCommerceGateway( initialConfig ); + + const axoConfig = window.wc_ppcp_axo; + + const fastlaneSdk = useFastlaneSdk( namespace, axoConfig, ppcpConfig ); const tokenizedCustomerData = useTokenizeCustomerData(); const handlePaymentSetup = useHandlePaymentSetup( emitResponse, @@ -35,7 +35,13 @@ const Axo = ( props ) => { tokenizedCustomerData ); - useAxoSetup( ppcpConfig, fastlaneSdk, paymentComponent ); + const isScriptLoaded = useAxoSetup( + namespace, + ppcpConfig, + isConfigLoaded, + fastlaneSdk, + paymentComponent + ); const { handlePaymentLoad } = usePaymentSetupEffect( onPaymentSetup, @@ -45,31 +51,57 @@ const Axo = ( props ) => { useAxoCleanup(); - return fastlaneSdk ? ( + if ( ! isConfigLoaded ) { + return ( + <> + { __( + 'Loading configuration…', + 'woocommerce-paypal-payments' + ) } + + ); + } + + if ( ! isScriptLoaded ) { + return ( + <> + { __( + 'Loading PayPal script…', + 'woocommerce-paypal-payments' + ) } + + ); + } + + if ( ! fastlaneSdk ) { + return ( + <>{ __( 'Loading Fastlane…', 'woocommerce-paypal-payments' ) } + ); + } + + return ( - ) : ( - <>{ __( 'Loading Fastlane…', 'woocommerce-paypal-payments' ) } ); }; registerPaymentMethod( { - name: ppcpConfig.id, + name: initialConfig.id, label: (
), content: , - edit: createElement( ppcpConfig.title ), - ariaLabel: ppcpConfig.title, + edit: createElement( initialConfig.title ), + ariaLabel: initialConfig.title, canMakePayment: () => true, supports: { showSavedCards: true, - features: ppcpConfig.supports, + features: initialConfig.supports, }, } ); diff --git a/modules/ppcp-axo-block/resources/js/stores/axoStore.js b/modules/ppcp-axo-block/resources/js/stores/axoStore.js index 04323c6e1..f779f983c 100644 --- a/modules/ppcp-axo-block/resources/js/stores/axoStore.js +++ b/modules/ppcp-axo-block/resources/js/stores/axoStore.js @@ -2,8 +2,8 @@ import { createReduxStore, register, dispatch } from '@wordpress/data'; export const STORE_NAME = 'woocommerce-paypal-payments/axo-block'; -// Initial state const DEFAULT_STATE = { + isPayPalLoaded: false, isGuest: true, isAxoActive: false, isAxoScriptLoaded: false, @@ -14,8 +14,12 @@ const DEFAULT_STATE = { phoneNumber: '', }; -// Actions +// Action creators for updating the store state const actions = { + setIsPayPalLoaded: ( isPayPalLoaded ) => ( { + type: 'SET_IS_PAYPAL_LOADED', + payload: isPayPalLoaded, + } ), setIsGuest: ( isGuest ) => ( { type: 'SET_IS_GUEST', payload: isGuest, @@ -50,9 +54,17 @@ const actions = { } ), }; -// Reducer +/** + * Reducer function to handle state updates based on dispatched actions. + * + * @param {Object} state - Current state of the store. + * @param {Object} action - Dispatched action object. + * @return {Object} New state after applying the action. + */ const reducer = ( state = DEFAULT_STATE, action ) => { switch ( action.type ) { + case 'SET_IS_PAYPAL_LOADED': + return { ...state, isPayPalLoaded: action.payload }; case 'SET_IS_GUEST': return { ...state, isGuest: action.payload }; case 'SET_IS_AXO_ACTIVE': @@ -74,8 +86,9 @@ const reducer = ( state = DEFAULT_STATE, action ) => { } }; -// Selectors +// Selector functions to retrieve specific pieces of state const selectors = { + getIsPayPalLoaded: ( state ) => state.isPayPalLoaded, getIsGuest: ( state ) => state.isGuest, getIsAxoActive: ( state ) => state.isAxoActive, getIsAxoScriptLoaded: ( state ) => state.isAxoScriptLoaded, @@ -86,7 +99,7 @@ const selectors = { getPhoneNumber: ( state ) => state.phoneNumber, }; -// Create and register the store +// Create and register the Redux store for the AXO block const store = createReduxStore( STORE_NAME, { reducer, actions, @@ -96,22 +109,57 @@ const store = createReduxStore( STORE_NAME, { register( store ); // Action dispatchers + +/** + * Action dispatcher to update the PayPal script load status in the store. + * + * @param {boolean} isPayPalLoaded - Whether the PayPal script has loaded. + */ +export const setIsPayPalLoaded = ( isPayPalLoaded ) => { + dispatch( STORE_NAME ).setIsPayPalLoaded( isPayPalLoaded ); +}; + +/** + * Action dispatcher to update the guest status in the store. + * + * @param {boolean} isGuest - Whether the user is a guest or not. + */ export const setIsGuest = ( isGuest ) => { dispatch( STORE_NAME ).setIsGuest( isGuest ); }; +/** + * Action dispatcher to update the email lookup completion status in the store. + * + * @param {boolean} isEmailLookupCompleted - Whether the email lookup is completed. + */ export const setIsEmailLookupCompleted = ( isEmailLookupCompleted ) => { dispatch( STORE_NAME ).setIsEmailLookupCompleted( isEmailLookupCompleted ); }; +/** + * Action dispatcher to update the shipping address in the store. + * + * @param {Object} shippingAddress - The user's shipping address. + */ export const setShippingAddress = ( shippingAddress ) => { dispatch( STORE_NAME ).setShippingAddress( shippingAddress ); }; +/** + * Action dispatcher to update the card details in the store. + * + * @param {Object} cardDetails - The user's card details. + */ export const setCardDetails = ( cardDetails ) => { dispatch( STORE_NAME ).setCardDetails( cardDetails ); }; +/** + * Action dispatcher to update the phone number in the store. + * + * @param {string} phoneNumber - The user's phone number. + */ export const setPhoneNumber = ( phoneNumber ) => { dispatch( STORE_NAME ).setPhoneNumber( phoneNumber ); }; diff --git a/modules/ppcp-axo-block/src/AxoBlockModule.php b/modules/ppcp-axo-block/src/AxoBlockModule.php index 2a52e0510..669cc7cc5 100644 --- a/modules/ppcp-axo-block/src/AxoBlockModule.php +++ b/modules/ppcp-axo-block/src/AxoBlockModule.php @@ -102,7 +102,14 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule add_action( 'woocommerce_blocks_payment_method_type_registration', function( PaymentMethodRegistry $payment_method_registry ) use ( $c ): void { - $payment_method_registry->register( $c->get( 'axoblock.method' ) ); + /* + * Only register the method if we are not in the admin + * (to avoid two Debit & Credit Cards gateways in the + * checkout block in the editor: one from ACDC one from Axo). + */ + if ( ! is_admin() ) { + $payment_method_registry->register( $c->get( 'axoblock.method' ) ); + } } ); diff --git a/modules/ppcp-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index d315ce374..0bd204672 100644 --- a/modules/ppcp-axo/resources/js/AxoManager.js +++ b/modules/ppcp-axo/resources/js/AxoManager.js @@ -52,11 +52,12 @@ class AxoManager { billingView = null; cardView = null; - constructor( axoConfig, ppcpConfig ) { + constructor( namespace, axoConfig, ppcpConfig ) { + this.namespace = namespace; this.axoConfig = axoConfig; this.ppcpConfig = ppcpConfig; - this.fastlane = new Fastlane(); + this.fastlane = new Fastlane( namespace ); this.$ = jQuery; this.status = { @@ -801,8 +802,6 @@ class AxoManager { } async onChangeEmail() { - this.clearData(); - if ( ! this.status.active ) { log( 'Email checking skipped, AXO not active.' ); return; @@ -813,11 +812,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/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,