diff --git a/modules/ppcp-api-client/src/Endpoint/PaymentMethodTokensEndpoint.php b/modules/ppcp-api-client/src/Endpoint/PaymentMethodTokensEndpoint.php index 538ca224d..e2c2a91e8 100644 --- a/modules/ppcp-api-client/src/Endpoint/PaymentMethodTokensEndpoint.php +++ b/modules/ppcp-api-client/src/Endpoint/PaymentMethodTokensEndpoint.php @@ -61,19 +61,24 @@ class PaymentMethodTokensEndpoint { * Creates a setup token. * * @param PaymentSource $payment_source The payment source. + * @param string $customer_id PayPal customer ID. * * @return stdClass * * @throws RuntimeException When something when wrong with the request. * @throws PayPalApiException When something when wrong setting up the token. */ - public function setup_tokens( PaymentSource $payment_source ): stdClass { + public function setup_tokens( PaymentSource $payment_source, string $customer_id = '' ): stdClass { $data = array( 'payment_source' => array( $payment_source->name() => $payment_source->properties(), ), ); + if ( $customer_id ) { + $data['customer']['id'] = $customer_id; + } + $bearer = $this->bearer->bearer(); $url = trailingslashit( $this->host ) . 'v3/vault/setup-tokens'; @@ -109,19 +114,24 @@ class PaymentMethodTokensEndpoint { * Creates a payment token for the given payment source. * * @param PaymentSource $payment_source The payment source. + * @param string $customer_id PayPal customer ID. * * @return stdClass * * @throws RuntimeException When something when wrong with the request. * @throws PayPalApiException When something when wrong setting up the token. */ - public function create_payment_token( PaymentSource $payment_source ): stdClass { + public function create_payment_token( PaymentSource $payment_source, string $customer_id = '' ): stdClass { $data = array( 'payment_source' => array( $payment_source->name() => $payment_source->properties(), ), ); + if ( $customer_id ) { + $data['customer']['id'] = $customer_id; + } + $bearer = $this->bearer->bearer(); $url = trailingslashit( $this->host ) . 'v3/vault/payment-tokens'; diff --git a/modules/ppcp-applepay/resources/js/ApplepayButton.js b/modules/ppcp-applepay/resources/js/ApplepayButton.js index 482557327..155ab5d7b 100644 --- a/modules/ppcp-applepay/resources/js/ApplepayButton.js +++ b/modules/ppcp-applepay/resources/js/ApplepayButton.js @@ -92,6 +92,24 @@ class ApplePayButton extends PaymentButton { */ #product = {}; + /** + * The start time of the configuration process. + * @type {number} + */ + #configureStartTime = 0; + + /** + * The maximum time to wait for buttonAttributes before proceeding with initialization. + * @type {number} + */ + #maxWaitTime = 1000; + + /** + * The stored button attributes. + * @type {Object|null} + */ + #storedButtonAttributes = null; + /** * @inheritDoc */ @@ -125,7 +143,8 @@ class ApplePayButton extends PaymentButton { externalHandler, buttonConfig, ppcpConfig, - contextHandler + contextHandler, + buttonAttributes ) { // Disable debug output in the browser console: // buttonConfig.is_debug = false; @@ -135,7 +154,8 @@ class ApplePayButton extends PaymentButton { externalHandler, buttonConfig, ppcpConfig, - contextHandler + contextHandler, + buttonAttributes ); this.init = this.init.bind( this ); @@ -220,6 +240,20 @@ class ApplePayButton extends PaymentButton { 'No transactionInfo - missing configure() call?' ); + invalidIf( + () => + this.buttonAttributes?.height && + isNaN( parseInt( this.buttonAttributes.height ) ), + 'Invalid height in buttonAttributes' + ); + + invalidIf( + () => + this.buttonAttributes?.borderRadius && + isNaN( parseInt( this.buttonAttributes.borderRadius ) ), + 'Invalid borderRadius in buttonAttributes' + ); + invalidIf( () => ! this.contextHandler?.validateContext(), `Invalid context handler.` @@ -229,12 +263,60 @@ class ApplePayButton extends PaymentButton { /** * Configures the button instance. Must be called before the initial `init()`. * - * @param {Object} apiConfig - API configuration. - * @param {TransactionInfo} transactionInfo - Transaction details. + * @param {Object} apiConfig - API configuration. + * @param {TransactionInfo} transactionInfo - Transaction details. + * @param {Object} buttonAttributes - Button attributes. */ - configure( apiConfig, transactionInfo ) { + configure( apiConfig, transactionInfo, buttonAttributes = {} ) { + // Start timing on first configure call + if ( ! this.#configureStartTime ) { + this.#configureStartTime = Date.now(); + } + + // If valid buttonAttributes, store them + if ( buttonAttributes?.height && buttonAttributes?.borderRadius ) { + this.#storedButtonAttributes = { ...buttonAttributes }; + } + + // Use stored attributes if current ones are missing + const attributes = buttonAttributes?.height + ? buttonAttributes + : this.#storedButtonAttributes; + + // Check if we've exceeded wait time + const timeWaited = Date.now() - this.#configureStartTime; + if ( timeWaited > this.#maxWaitTime ) { + this.log( + 'ApplePay: Timeout waiting for buttonAttributes - proceeding with initialization' + ); + this.#applePayConfig = apiConfig; + this.#transactionInfo = transactionInfo; + this.buttonAttributes = attributes || buttonAttributes; + this.init(); + return; + } + + // Block any initialization until we have valid buttonAttributes + if ( ! attributes?.height || ! attributes?.borderRadius ) { + setTimeout( + () => + this.configure( + apiConfig, + transactionInfo, + buttonAttributes + ), + 100 + ); + return; + } + + // Reset timer for future configure calls + this.#configureStartTime = 0; + this.#applePayConfig = apiConfig; this.#transactionInfo = transactionInfo; + this.buttonAttributes = attributes; + this.init(); } init() { @@ -321,17 +403,43 @@ class ApplePayButton extends PaymentButton { applyWrapperStyles() { super.applyWrapperStyles(); - const { height } = this.style; + const wrapper = this.wrapperElement; + if ( ! wrapper ) { + return; + } - if ( height ) { - const wrapper = this.wrapperElement; + // Try stored attributes if current ones are missing + const attributes = + this.buttonAttributes?.height || this.buttonAttributes?.borderRadius + ? this.buttonAttributes + : this.#storedButtonAttributes; + const defaultHeight = 48; + const defaultBorderRadius = 4; + + const height = attributes?.height + ? parseInt( attributes.height, 10 ) + : defaultHeight; + + if ( ! isNaN( height ) ) { wrapper.style.setProperty( '--apple-pay-button-height', `${ height }px` ); - wrapper.style.height = `${ height }px`; + } else { + wrapper.style.setProperty( + '--apple-pay-button-height', + `${ defaultHeight }px` + ); + wrapper.style.height = `${ defaultHeight }px`; + } + + const borderRadius = attributes?.borderRadius + ? parseInt( attributes.borderRadius, 10 ) + : defaultBorderRadius; + if ( ! isNaN( borderRadius ) ) { + wrapper.style.borderRadius = `${ borderRadius }px`; } } @@ -342,12 +450,23 @@ class ApplePayButton extends PaymentButton { addButton() { const { color, type, language } = this.style; + // If current buttonAttributes are missing, try to use stored ones + if ( + ! this.buttonAttributes?.height && + this.#storedButtonAttributes?.height + ) { + this.buttonAttributes = { ...this.#storedButtonAttributes }; + } + const button = document.createElement( 'apple-pay-button' ); button.id = 'apple-' + this.wrapperId; + button.setAttribute( 'buttonstyle', color ); button.setAttribute( 'type', type ); button.setAttribute( 'locale', language ); + button.style.display = 'block'; + button.addEventListener( 'click', ( evt ) => { evt.preventDefault(); this.onButtonClick(); diff --git a/modules/ppcp-applepay/resources/js/ApplepayManager.js b/modules/ppcp-applepay/resources/js/ApplepayManager.js index 673078121..794464f70 100644 --- a/modules/ppcp-applepay/resources/js/ApplepayManager.js +++ b/modules/ppcp-applepay/resources/js/ApplepayManager.js @@ -3,65 +3,83 @@ import ApplePayButton from './ApplepayButton'; import ContextHandlerFactory from './Context/ContextHandlerFactory'; class ApplePayManager { - #namespace = ''; - #buttonConfig = null; - #ppcpConfig = null; - #applePayConfig = null; - #contextHandler = null; - #transactionInfo = null; - #buttons = []; + constructor( namespace, buttonConfig, ppcpConfig, buttonAttributes = {} ) { + this.namespace = namespace; + this.buttonConfig = buttonConfig; + this.ppcpConfig = ppcpConfig; + this.buttonAttributes = buttonAttributes; + this.applePayConfig = null; + this.transactionInfo = null; + this.contextHandler = null; - constructor( namespace, buttonConfig, ppcpConfig ) { - this.#namespace = namespace; - this.#buttonConfig = buttonConfig; - this.#ppcpConfig = ppcpConfig; + 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 = ApplePayButton.createButton( + bootstrap.context, + bootstrap.handler, + buttonConfig, + ppcpConfig, + this.contextHandler, + this.buttonAttributes + ); - const button = ApplePayButton.createButton( - bootstrap.context, - bootstrap.handler, - this.#buttonConfig, - this.#ppcpConfig, - this.#contextHandler - ); + this.buttons.push( button ); + const initButton = () => { + button.configure( + this.applePayConfig, + this.transactionInfo, + this.buttonAttributes + ); + button.init(); + }; - this.#buttons.push( button ); + // Initialize button only if applePayConfig and transactionInfo are already fetched. + if ( this.applePayConfig && this.transactionInfo ) { + initButton(); + } else { + // Ensure ApplePayConfig is loaded before proceeding. + await this.init(); - // Ensure ApplePayConfig is loaded before proceeding. - await this.init(); - - button.configure( this.#applePayConfig, this.#transactionInfo ); - button.init(); + if ( this.applePayConfig && this.transactionInfo ) { + initButton(); + } + } + } ); } async init() { try { - if ( ! this.#applePayConfig ) { - this.#applePayConfig = await window[ this.#namespace ] + if ( ! this.applePayConfig ) { + // Gets ApplePay configuration of the PayPal merchant. + this.applePayConfig = await window[ this.namespace ] .Applepay() .config(); - - if ( ! this.#applePayConfig ) { - console.error( 'No ApplePayConfig received during init' ); - } } - if ( ! this.#transactionInfo ) { - this.#transactionInfo = await this.fetchTransactionInfo(); + if ( ! this.transactionInfo ) { + this.transactionInfo = await this.fetchTransactionInfo(); + } - if ( ! this.#applePayConfig ) { - console.error( 'No transactionInfo found during init' ); + if ( ! this.applePayConfig ) { + console.error( 'No ApplePayConfig received during init' ); + } else if ( ! this.transactionInfo ) { + console.error( 'No transactionInfo found during init' ); + } else { + for ( const button of this.buttons ) { + button.configure( + this.applePayConfig, + this.transactionInfo, + this.buttonAttributes + ); + button.init(); } } } catch ( error ) { @@ -71,10 +89,10 @@ class ApplePayManager { async fetchTransactionInfo() { try { - if ( ! this.#contextHandler ) { + if ( ! this.contextHandler ) { throw new Error( 'ContextHandler is not initialized' ); } - return await this.#contextHandler.transactionInfo(); + return await this.contextHandler.transactionInfo(); } catch ( error ) { console.error( 'Error fetching transaction info:', error ); throw error; @@ -82,7 +100,7 @@ class ApplePayManager { } reinit() { - for ( const button of this.#buttons ) { + for ( const button of this.buttons ) { button.reinit(); } } diff --git a/modules/ppcp-applepay/resources/js/ApplepayManagerBlockEditor.js b/modules/ppcp-applepay/resources/js/ApplepayManagerBlockEditor.js index 0d60929e4..3381baf93 100644 --- a/modules/ppcp-applepay/resources/js/ApplepayManagerBlockEditor.js +++ b/modules/ppcp-applepay/resources/js/ApplepayManagerBlockEditor.js @@ -4,11 +4,13 @@ const ApplePayManagerBlockEditor = ( { namespace, buttonConfig, ppcpConfig, + buttonAttributes, } ) => ( ); diff --git a/modules/ppcp-applepay/resources/js/Block/components/ApplePayButton.js b/modules/ppcp-applepay/resources/js/Block/components/ApplePayButton.js index c01f04320..8b4985a1f 100644 --- a/modules/ppcp-applepay/resources/js/Block/components/ApplePayButton.js +++ b/modules/ppcp-applepay/resources/js/Block/components/ApplePayButton.js @@ -4,7 +4,12 @@ import usePayPalScript from '../hooks/usePayPalScript'; import useApplepayScript from '../hooks/useApplepayScript'; import useApplepayConfig from '../hooks/useApplepayConfig'; -const ApplepayButton = ( { namespace, buttonConfig, ppcpConfig } ) => { +const ApplepayButton = ( { + namespace, + buttonConfig, + ppcpConfig, + buttonAttributes, +} ) => { const [ buttonHtml, setButtonHtml ] = useState( '' ); const [ buttonElement, setButtonElement ] = useState( null ); const [ componentFrame, setComponentFrame ] = useState( null ); @@ -31,19 +36,42 @@ const ApplepayButton = ( { namespace, buttonConfig, ppcpConfig } ) => { namespace, buttonConfig, ppcpConfig, - applepayConfig + applepayConfig, + buttonAttributes ); useEffect( () => { - if ( applepayButton ) { - setButtonHtml( applepayButton.outerHTML ); + if ( ! applepayButton || ! buttonElement ) { + return; } - }, [ applepayButton ] ); + + setButtonHtml( applepayButton.outerHTML ); + + // Add timeout to ensure button is displayed after render + setTimeout( () => { + const button = buttonElement.querySelector( 'apple-pay-button' ); + if ( button ) { + button.style.display = 'block'; + } + }, 100 ); // Add a small delay to ensure DOM is ready + }, [ applepayButton, buttonElement ] ); return (
); }; diff --git a/modules/ppcp-applepay/resources/js/boot-block.js b/modules/ppcp-applepay/resources/js/boot-block.js index 8013177d0..eff026251 100644 --- a/modules/ppcp-applepay/resources/js/boot-block.js +++ b/modules/ppcp-applepay/resources/js/boot-block.js @@ -19,7 +19,7 @@ if ( typeof window.PayPalCommerceGateway === 'undefined' ) { window.PayPalCommerceGateway = ppcpConfig; } -const ApplePayComponent = ( { isEditing } ) => { +const ApplePayComponent = ( { isEditing, buttonAttributes } ) => { const [ paypalLoaded, setPaypalLoaded ] = useState( false ); const [ applePayLoaded, setApplePayLoaded ] = useState( false ); const wrapperRef = useRef( null ); @@ -57,8 +57,13 @@ const ApplePayComponent = ( { isEditing } ) => { buttonConfig.reactWrapper = wrapperRef.current; - new ManagerClass( namespace, buttonConfig, ppcpConfig ); - }, [ paypalLoaded, applePayLoaded, isEditing ] ); + new ManagerClass( + namespace, + buttonConfig, + ppcpConfig, + buttonAttributes + ); + }, [ paypalLoaded, applePayLoaded, isEditing, buttonAttributes ] ); if ( isEditing ) { return ( @@ -66,6 +71,7 @@ const ApplePayComponent = ( { isEditing } ) => { namespace={ namespace } buttonConfig={ buttonConfig } ppcpConfig={ ppcpConfig } + buttonAttributes={ buttonAttributes } /> ); } @@ -102,5 +108,6 @@ registerExpressPaymentMethod( { canMakePayment: () => buttonData.enabled, supports: { features, + style: [ 'height', 'borderRadius' ], }, } ); diff --git a/modules/ppcp-axo-block/resources/css/gateway.scss b/modules/ppcp-axo-block/resources/css/gateway.scss index b1296ea1b..4611bfa35 100644 --- a/modules/ppcp-axo-block/resources/css/gateway.scss +++ b/modules/ppcp-axo-block/resources/css/gateway.scss @@ -17,10 +17,19 @@ $fast-transition-duration: 0.5s; } // 1. AXO Block Radio Label -#ppcp-axo-block-radio-label { - @include flex-space-between; +.wc-block-checkout__payment-method label[for="radio-control-wc-payment-method-options-ppcp-axo-gateway"] { + padding-right: .875em; +} + +#radio-control-wc-payment-method-options-ppcp-axo-gateway__label { + display: flex; + align-items: center; width: 100%; padding-right: 1em; + + .wc-block-components-payment-method-icons { + margin: 0; + } } // 2. AXO Block Card @@ -70,15 +79,16 @@ $fast-transition-duration: 0.5s; } &__edit { - background-color: transparent; + flex-grow: 1; + margin-left: auto; + text-align: right; border: 0; - color: inherit; - cursor: pointer; - display: block; font-family: inherit; - margin: 0 0 0 auto; font-size: 0.875em; font-weight: normal; + color: inherit; + background-color: transparent; + cursor: pointer; &:hover { text-decoration: underline; 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 c2a4eaa65..d3b27b17a 100644 --- a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButton.js +++ b/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButton.js @@ -1,15 +1,28 @@ import { createElement } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; +import { STORE_NAME } from '../../stores/axoStore'; /** * 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. + * @return {JSX.Element|null} The rendered button as an anchor tag, or null if conditions aren't met. */ -const CardChangeButton = ( { onChangeButtonClick } ) => - createElement( +const CardChangeButton = () => { + const { isGuest, cardDetails, cardChangeHandler } = useSelect( + ( select ) => ( { + isGuest: select( STORE_NAME ).getIsGuest(), + cardDetails: select( STORE_NAME ).getCardDetails(), + cardChangeHandler: select( STORE_NAME ).getCardChangeHandler(), + } ), + [] + ); + + if ( isGuest || ! cardDetails || ! cardChangeHandler ) { + return null; + } + + return createElement( 'a', { className: @@ -19,10 +32,11 @@ const CardChangeButton = ( { onChangeButtonClick } ) => // Prevent default anchor behavior event.preventDefault(); // Call the provided click handler - onChangeButtonClick(); + cardChangeHandler(); }, }, __( 'Choose a different card', 'woocommerce-paypal-payments' ) ); +}; export default CardChangeButton; diff --git a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js b/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js deleted file mode 100644 index 2b6deba4f..000000000 --- a/modules/ppcp-axo-block/resources/js/components/Card/CardChangeButtonManager.js +++ /dev/null @@ -1,51 +0,0 @@ -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( - 'ppcp-axo-block-radio-label' - ); - - 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 } ) - ); - } - } - - // Cleanup function to remove the button when the component unmounts - return () => { - const button = document.querySelector( - '.wc-block-checkout-axo-block-card__edit' - ); - if ( button && button.parentNode ) { - button.parentNode.remove(); - } - }; - }, [ onChangeButtonClick ] ); - - // This component doesn't render anything directly - return null; -}; - -export default CardChangeButtonManager; diff --git a/modules/ppcp-axo-block/resources/js/components/Card/index.js b/modules/ppcp-axo-block/resources/js/components/Card/index.js index c9250078e..7c44148bc 100644 --- a/modules/ppcp-axo-block/resources/js/components/Card/index.js +++ b/modules/ppcp-axo-block/resources/js/components/Card/index.js @@ -1,4 +1,2 @@ export { default as Card } from './Card'; export { default as CardChangeButton } from './CardChangeButton'; -export { default as CardChangeButtonManager } from './CardChangeButtonManager'; -export { injectCardChangeButton, removeCardChangeButton } from './utils'; diff --git a/modules/ppcp-axo-block/resources/js/components/Card/utils.js b/modules/ppcp-axo-block/resources/js/components/Card/utils.js deleted file mode 100644 index 915511885..000000000 --- a/modules/ppcp-axo-block/resources/js/components/Card/utils.js +++ /dev/null @@ -1,32 +0,0 @@ -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/TitleLabel/TitleLabel.js b/modules/ppcp-axo-block/resources/js/components/TitleLabel/TitleLabel.js new file mode 100644 index 000000000..6435e23de --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/components/TitleLabel/TitleLabel.js @@ -0,0 +1,24 @@ +import CardChangeButton from './../Card/CardChangeButton'; + +/** + * TitleLabel component for displaying a payment method title with icons and a change card button. + * + * @param {Object} props - Component props + * @param {Object} props.components - Object containing WooCommerce components + * @param {Object} props.config - Configuration object for the payment method + * @return {JSX.Element} WordPress element + */ +const TitleLabel = ( { components, config } ) => { + const axoConfig = window.wc_ppcp_axo; + const { PaymentMethodIcons } = components; + + return ( + <> + + + + + ); +}; + +export default TitleLabel; diff --git a/modules/ppcp-axo-block/resources/js/components/TitleLabel/index.js b/modules/ppcp-axo-block/resources/js/components/TitleLabel/index.js new file mode 100644 index 000000000..9ca79db8f --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/components/TitleLabel/index.js @@ -0,0 +1 @@ +export { default as TitleLabel } from './TitleLabel'; diff --git a/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js b/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js index 563f9510d..8cc887668 100644 --- a/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js +++ b/modules/ppcp-axo-block/resources/js/events/emailLookupManager.js @@ -1,7 +1,6 @@ import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; import { populateWooFields } from '../helpers/fieldHelpers'; import { injectShippingChangeButton } from '../components/Shipping'; -import { injectCardChangeButton } from '../components/Card'; import { setIsGuest, setIsEmailLookupCompleted } from '../stores/axoStore'; /** @@ -16,7 +15,6 @@ import { setIsGuest, setIsEmailLookupCompleted } from '../stores/axoStore'; * @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 = ( @@ -28,8 +26,7 @@ export const createEmailLookupHandler = ( wooBillingAddress, setWooShippingAddress, setWooBillingAddress, - onChangeShippingAddressClick, - onChangeCardButtonClick + onChangeShippingAddressClick ) => { return async ( email ) => { try { @@ -102,9 +99,8 @@ export const createEmailLookupHandler = ( setWooBillingAddress ); - // Inject change buttons for shipping and card + // Inject the change button for shipping injectShippingChangeButton( onChangeShippingAddressClick ); - injectCardChangeButton( onChangeCardButtonClick ); } else { log( 'Authentication failed or did not succeed', 'warn' ); } diff --git a/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js b/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js index 1da3cca85..07247f2e3 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useAxoCleanup.js @@ -3,7 +3,6 @@ import { useDispatch } from '@wordpress/data'; import { log } from '../../../../ppcp-axo/resources/js/Helper/Debug'; import { STORE_NAME } from '../stores/axoStore'; import { removeShippingChangeButton } from '../components/Shipping'; -import { removeCardChangeButton } from '../components/Card'; import { removeWatermark } from '../components/Watermark'; import { removeEmailFunctionality, @@ -50,7 +49,6 @@ const useAxoCleanup = () => { // Remove AXO UI elements removeShippingChangeButton(); - removeCardChangeButton(); removeWatermark(); // Remove email functionality if it was set up diff --git a/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js b/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js index af1aaca38..629ce05d4 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useAxoSetup.js @@ -35,6 +35,7 @@ const useAxoSetup = ( setIsAxoScriptLoaded, setShippingAddress, setCardDetails, + setCardChangeHandler, } = useDispatch( STORE_NAME ); // Check if PayPal script has loaded @@ -73,6 +74,7 @@ const useAxoSetup = ( if ( paypalLoaded && fastlaneSdk ) { setIsAxoScriptLoaded( true ); setIsAxoActive( true ); + setCardChangeHandler( onChangeCardButtonClick ); // Create and set up email lookup handler const emailLookupHandler = createEmailLookupHandler( @@ -84,8 +86,7 @@ const useAxoSetup = ( wooBillingAddress, setWooShippingAddress, setWooBillingAddress, - onChangeShippingAddressClick, - onChangeCardButtonClick + onChangeShippingAddressClick ); setupEmailFunctionality( emailLookupHandler ); } diff --git a/modules/ppcp-axo-block/resources/js/hooks/useCardOptions.js b/modules/ppcp-axo-block/resources/js/hooks/useCardOptions.js new file mode 100644 index 000000000..94c727784 --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/hooks/useCardOptions.js @@ -0,0 +1,36 @@ +import { useMemo } from '@wordpress/element'; + +const DEFAULT_ALLOWED_CARDS = [ 'VISA', 'MASTERCARD', 'AMEX', 'DISCOVER' ]; + +/** + * Custom hook to determine the allowed card options based on configuration. + * + * @param {Object} axoConfig - The AXO configuration object. + * @return {Array} The final list of allowed card options. + */ +const useCardOptions = ( axoConfig ) => { + const merchantCountry = axoConfig.merchant_country || 'US'; + + return useMemo( () => { + const allowedCards = new Set( + axoConfig.allowed_cards?.[ merchantCountry ] || + DEFAULT_ALLOWED_CARDS + ); + + // Create a Set of disabled cards, converting each to uppercase + const disabledCards = new Set( + ( axoConfig.disable_cards || [] ).map( ( card ) => + card.toUpperCase() + ) + ); + + // Filter out disabled cards from the allowed cards + const finalCardOptions = [ ...allowedCards ].filter( + ( card ) => ! disabledCards.has( card ) + ); + + return finalCardOptions; + }, [ axoConfig.allowed_cards, axoConfig.disable_cards, merchantCountry ] ); +}; + +export default useCardOptions; diff --git a/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js b/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js index ae93a67af..47a917644 100644 --- a/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js +++ b/modules/ppcp-axo-block/resources/js/hooks/useFastlaneSdk.js @@ -3,6 +3,7 @@ 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 useCardOptions from './useCardOptions'; import useAllowedLocations from './useAllowedLocations'; import { STORE_NAME } from '../stores/axoStore'; @@ -27,6 +28,8 @@ const useFastlaneSdk = ( namespace, axoConfig, ppcpConfig ) => { [] ); + const cardOptions = useCardOptions( axoConfig ); + const styleOptions = useMemo( () => { return deleteEmptyKeys( configRef.current.axoConfig.style_options ); }, [ deleteEmptyKeys ] ); @@ -51,10 +54,13 @@ const useFastlaneSdk = ( namespace, axoConfig, ppcpConfig ) => { window.localStorage.setItem( 'axoEnv', 'sandbox' ); } - // Connect to Fastlane with locale and style options + // Connect to Fastlane with locale, style options, and allowed card brands await fastlane.connect( { locale: configRef.current.ppcpConfig.locale, styles: styleOptions, + cardOptions: { + allowedBrands: cardOptions, + }, shippingAddressOptions: { allowedLocations, }, @@ -77,6 +83,7 @@ const useFastlaneSdk = ( namespace, axoConfig, ppcpConfig ) => { styleOptions, isPayPalLoaded, namespace, + cardOptions, allowedLocations, ] ); diff --git a/modules/ppcp-axo-block/resources/js/index.js b/modules/ppcp-axo-block/resources/js/index.js index a45473e50..f58279af5 100644 --- a/modules/ppcp-axo-block/resources/js/index.js +++ b/modules/ppcp-axo-block/resources/js/index.js @@ -13,6 +13,7 @@ import usePayPalCommerceGateway from './hooks/usePayPalCommerceGateway'; // Components import { Payment } from './components/Payment/Payment'; +import { TitleLabel } from './components/TitleLabel'; const gatewayHandle = 'ppcp-axo-gateway'; const namespace = 'ppcpBlocksPaypalAxo'; @@ -89,12 +90,7 @@ const Axo = ( props ) => { registerPaymentMethod( { name: initialConfig.id, - label: ( -
- ), + label: , content: , edit: createElement( initialConfig.title ), ariaLabel: initialConfig.title, diff --git a/modules/ppcp-axo-block/resources/js/plugins/PayPalInsightsLoader.js b/modules/ppcp-axo-block/resources/js/plugins/PayPalInsightsLoader.js new file mode 100644 index 000000000..b831bd45b --- /dev/null +++ b/modules/ppcp-axo-block/resources/js/plugins/PayPalInsightsLoader.js @@ -0,0 +1,259 @@ +import { registerPlugin } from '@wordpress/plugins'; +import { useEffect, useCallback, useState, useRef } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { PAYMENT_STORE_KEY } from '@woocommerce/block-data'; +import PayPalInsights from '../../../../ppcp-axo/resources/js/Insights/PayPalInsights'; +import { STORE_NAME } from '../stores/axoStore'; +import usePayPalCommerceGateway from '../hooks/usePayPalCommerceGateway'; + +const GATEWAY_HANDLE = 'ppcp-axo-gateway'; + +const useEventTracking = () => { + const [ triggeredEvents, setTriggeredEvents ] = useState( { + initialized: false, + jsLoaded: false, + beginCheckout: false, + emailSubmitted: false, + } ); + + const currentPaymentMethod = useRef( null ); + + const setEventTriggered = useCallback( ( eventName, value = true ) => { + setTriggeredEvents( ( prev ) => ( { + ...prev, + [ eventName ]: value, + } ) ); + }, [] ); + + const isEventTriggered = useCallback( + ( eventName ) => triggeredEvents[ eventName ], + [ triggeredEvents ] + ); + + const setCurrentPaymentMethod = useCallback( ( methodName ) => { + currentPaymentMethod.current = methodName; + }, [] ); + + const getCurrentPaymentMethod = useCallback( + () => currentPaymentMethod.current, + [] + ); + + return { + setEventTriggered, + isEventTriggered, + setCurrentPaymentMethod, + getCurrentPaymentMethod, + }; +}; + +const waitForPayPalInsight = () => { + return new Promise( ( resolve, reject ) => { + // If already loaded, resolve immediately + if ( window.paypalInsight ) { + resolve( window.paypalInsight ); + return; + } + + // Set a reasonable timeout + const timeoutId = setTimeout( () => { + observer.disconnect(); + reject( new Error( 'PayPal Insights script load timeout' ) ); + }, 10000 ); + + // Create MutationObserver to watch for script initialization + const observer = new MutationObserver( () => { + if ( window.paypalInsight ) { + observer.disconnect(); + clearTimeout( timeoutId ); + resolve( window.paypalInsight ); + } + } ); + + // Start observing + observer.observe( document, { + childList: true, + subtree: true, + } ); + } ); +}; + +const usePayPalInsightsInit = ( axoConfig, ppcpConfig, eventTracking ) => { + const { setEventTriggered, isEventTriggered } = eventTracking; + const initialized = useRef( false ); + + useEffect( () => { + if ( + ! axoConfig?.insights?.enabled || + ! axoConfig?.insights?.client_id || + ! axoConfig?.insights?.session_id || + initialized.current || + isEventTriggered( 'initialized' ) + ) { + return; + } + + const initializePayPalInsights = async () => { + try { + await waitForPayPalInsight(); + + if ( initialized.current ) { + return; + } + + // Track JS load first + PayPalInsights.trackJsLoad(); + setEventTriggered( 'jsLoaded' ); + + PayPalInsights.config( axoConfig.insights.client_id, { + debug: axoConfig?.wp_debug === '1', + } ); + + PayPalInsights.setSessionId( axoConfig.insights.session_id ); + initialized.current = true; + setEventTriggered( 'initialized' ); + + if ( + isEventTriggered( 'jsLoaded' ) && + ! isEventTriggered( 'beginCheckout' ) + ) { + PayPalInsights.trackBeginCheckout( { + amount: axoConfig.insights.amount, + page_type: 'checkout', + user_data: { + country: 'US', + is_store_member: false, + }, + } ); + setEventTriggered( 'beginCheckout' ); + } + } catch ( error ) { + console.error( + 'PayPal Insights initialization failed:', + error + ); + } + }; + + initializePayPalInsights(); + + return () => { + initialized.current = false; + }; + }, [ axoConfig, ppcpConfig, setEventTriggered, isEventTriggered ] ); +}; + +const usePaymentMethodTracking = ( axoConfig, eventTracking ) => { + const { setCurrentPaymentMethod } = eventTracking; + const lastPaymentMethod = useRef( null ); + const isInitialMount = useRef( true ); + + const activePaymentMethod = useSelect( ( select ) => { + return select( PAYMENT_STORE_KEY )?.getActivePaymentMethod(); + }, [] ); + + const handlePaymentMethodChange = useCallback( + async ( paymentMethod ) => { + // Skip if no payment method or same as last one + if ( + ! paymentMethod || + paymentMethod === lastPaymentMethod.current + ) { + return; + } + + try { + await waitForPayPalInsight(); + + // Only track if it's not the initial mount, and we have a previous payment method + if ( ! isInitialMount.current && lastPaymentMethod.current ) { + PayPalInsights.trackSelectPaymentMethod( { + payment_method_selected: + axoConfig?.insights?.payment_method_selected_map[ + paymentMethod + ] || 'other', + page_type: 'checkout', + } ); + } + + lastPaymentMethod.current = paymentMethod; + setCurrentPaymentMethod( paymentMethod ); + } catch ( error ) { + console.error( 'Failed to track payment method:', error ); + } + }, + [ + axoConfig?.insights?.payment_method_selected_map, + setCurrentPaymentMethod, + ] + ); + + useEffect( () => { + if ( activePaymentMethod ) { + if ( isInitialMount.current ) { + // Just set the initial payment method without tracking + lastPaymentMethod.current = activePaymentMethod; + setCurrentPaymentMethod( activePaymentMethod ); + isInitialMount.current = false; + } else { + handlePaymentMethodChange( activePaymentMethod ); + } + } + }, [ + activePaymentMethod, + handlePaymentMethodChange, + setCurrentPaymentMethod, + ] ); + + useEffect( () => { + return () => { + lastPaymentMethod.current = null; + isInitialMount.current = true; + }; + }, [] ); +}; + +const PayPalInsightsLoader = () => { + const eventTracking = useEventTracking(); + const { setEventTriggered, isEventTriggered } = eventTracking; + + const initialConfig = + window?.wc?.wcSettings?.getSetting( `${ GATEWAY_HANDLE }_data` ) || {}; + + const { ppcpConfig } = usePayPalCommerceGateway( initialConfig ); + const axoConfig = window?.wc_ppcp_axo; + + const { isEmailSubmitted } = useSelect( ( select ) => { + const storeSelect = select( STORE_NAME ); + return { + isEmailSubmitted: storeSelect?.getIsEmailSubmitted?.() ?? false, + }; + }, [] ); + + usePayPalInsightsInit( axoConfig, ppcpConfig, eventTracking ); + usePaymentMethodTracking( axoConfig, eventTracking ); + + useEffect( () => { + const trackEmail = async () => { + if ( isEmailSubmitted && ! isEventTriggered( 'emailSubmitted' ) ) { + try { + await waitForPayPalInsight(); + PayPalInsights.trackSubmitCheckoutEmail(); + setEventTriggered( 'emailSubmitted' ); + } catch ( error ) { + console.error( 'Failed to track email submission:', error ); + } + } + }; + trackEmail(); + }, [ isEmailSubmitted, setEventTriggered, isEventTriggered ] ); + + return null; +}; + +registerPlugin( 'wc-ppcp-paypal-insights', { + render: PayPalInsightsLoader, + scope: 'woocommerce-checkout', +} ); + +export default PayPalInsightsLoader; diff --git a/modules/ppcp-axo-block/resources/js/stores/axoStore.js b/modules/ppcp-axo-block/resources/js/stores/axoStore.js index f779f983c..c9c5449a4 100644 --- a/modules/ppcp-axo-block/resources/js/stores/axoStore.js +++ b/modules/ppcp-axo-block/resources/js/stores/axoStore.js @@ -1,4 +1,4 @@ -import { createReduxStore, register, dispatch } from '@wordpress/data'; +import { createReduxStore, register, dispatch, select } from '@wordpress/data'; export const STORE_NAME = 'woocommerce-paypal-payments/axo-block'; @@ -12,6 +12,7 @@ const DEFAULT_STATE = { shippingAddress: null, cardDetails: null, phoneNumber: '', + cardChangeHandler: null, }; // Action creators for updating the store state @@ -52,6 +53,10 @@ const actions = { type: 'SET_PHONE_NUMBER', payload: phoneNumber, } ), + setCardChangeHandler: ( cardChangeHandler ) => ( { + type: 'SET_CARD_CHANGE_HANDLER', + payload: cardChangeHandler, + } ), }; /** @@ -81,6 +86,8 @@ const reducer = ( state = DEFAULT_STATE, action ) => { return { ...state, cardDetails: action.payload }; case 'SET_PHONE_NUMBER': return { ...state, phoneNumber: action.payload }; + case 'SET_CARD_CHANGE_HANDLER': + return { ...state, cardChangeHandler: action.payload }; default: return state; } @@ -97,16 +104,19 @@ const selectors = { getShippingAddress: ( state ) => state.shippingAddress, getCardDetails: ( state ) => state.cardDetails, getPhoneNumber: ( state ) => state.phoneNumber, + getCardChangeHandler: ( state ) => state.cardChangeHandler, }; // Create and register the Redux store for the AXO block -const store = createReduxStore( STORE_NAME, { - reducer, - actions, - selectors, -} ); +if ( ! select( STORE_NAME ) ) { + const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, + } ); -register( store ); + register( store ); +} // Action dispatchers @@ -163,3 +173,12 @@ export const setCardDetails = ( cardDetails ) => { export const setPhoneNumber = ( phoneNumber ) => { dispatch( STORE_NAME ).setPhoneNumber( phoneNumber ); }; + +/** + * Action dispatcher to update the card change handler in the store. + * + * @param {Function} cardChangeHandler - The card change handler function. + */ +export const setCardChangeHandler = ( cardChangeHandler ) => { + dispatch( STORE_NAME ).setCardChangeHandler( cardChangeHandler ); +}; diff --git a/modules/ppcp-axo-block/services.php b/modules/ppcp-axo-block/services.php index 35a253881..f91cd738a 100644 --- a/modules/ppcp-axo-block/services.php +++ b/modules/ppcp-axo-block/services.php @@ -33,11 +33,13 @@ return array( $container->get( 'axoblock.url' ), $container->get( 'ppcp.asset-version' ), $container->get( 'axo.gateway' ), - fn() : SmartButtonInterface => $container->get( 'button.smart-button' ), + fn(): SmartButtonInterface => $container->get( 'button.smart-button' ), $container->get( 'wcgateway.settings' ), $container->get( 'wcgateway.configuration.dcc' ), $container->get( 'onboarding.environment' ), $container->get( 'wcgateway.url' ), + $container->get( 'axo.payment_method_selected_map' ), + $container->get( 'axo.supported-country-card-type-matrix' ), $container->get( 'axo.shipping-wc-enabled-locations' ) ); }, diff --git a/modules/ppcp-axo-block/src/AxoBlockModule.php b/modules/ppcp-axo-block/src/AxoBlockModule.php index 669cc7cc5..1ebb068ed 100644 --- a/modules/ppcp-axo-block/src/AxoBlockModule.php +++ b/modules/ppcp-axo-block/src/AxoBlockModule.php @@ -133,6 +133,15 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule wp_enqueue_style( 'wc-ppcp-axo-block' ); } ); + + // Enqueue the PayPal Insights script. + add_action( + 'wp_enqueue_scripts', + function () use ( $c ) { + $this->enqueue_paypal_insights_script( $c ); + } + ); + return true; } @@ -166,4 +175,37 @@ class AxoBlockModule implements ServiceModule, ExtendingModule, ExecutableModule return $localized_script_data; } + + /** + * Enqueues PayPal Insights analytics script for the Checkout block. + * + * @param ContainerInterface $c The service container. + * @return void + */ + private function enqueue_paypal_insights_script( ContainerInterface $c ): void { + if ( ! has_block( 'woocommerce/checkout' ) || WC()->cart->is_empty() ) { + return; + } + + $module_url = $c->get( 'axoblock.url' ); + $asset_version = $c->get( 'ppcp.asset-version' ); + + wp_register_script( + 'wc-ppcp-paypal-insights', + untrailingslashit( $module_url ) . '/assets/js/PayPalInsightsLoader.js', + array( 'wp-plugins', 'wp-data', 'wp-element', 'wc-blocks-registry' ), + $asset_version, + true + ); + + wp_localize_script( + 'wc-ppcp-paypal-insights', + 'ppcpPayPalInsightsData', + array( + 'isAxoEnabled' => true, + ) + ); + + wp_enqueue_script( 'wc-ppcp-paypal-insights' ); + } } diff --git a/modules/ppcp-axo-block/src/AxoBlockPaymentMethod.php b/modules/ppcp-axo-block/src/AxoBlockPaymentMethod.php index e7d43608d..fa546d5ac 100644 --- a/modules/ppcp-axo-block/src/AxoBlockPaymentMethod.php +++ b/modules/ppcp-axo-block/src/AxoBlockPaymentMethod.php @@ -72,6 +72,13 @@ class AxoBlockPaymentMethod extends AbstractPaymentMethodType { */ private $environment; + /** + * Mapping of payment methods to the PayPal Insights 'payment_method_selected' types. + * + * @var array + */ + private array $payment_method_selected_map; + /** * The WcGateway module URL. * @@ -79,6 +86,13 @@ class AxoBlockPaymentMethod extends AbstractPaymentMethodType { */ private $wcgateway_module_url; + /** + * The supported country card type matrix. + * + * @var array + */ + private $supported_country_card_type_matrix; + /** * The list of WooCommerce enabled shipping locations. * @@ -89,40 +103,44 @@ class AxoBlockPaymentMethod extends AbstractPaymentMethodType { /** * AdvancedCardPaymentMethod constructor. * - * @param string $module_url The URL of this module. - * @param string $version The assets version. - * @param WC_Payment_Gateway $gateway Credit card gateway. - * @param SmartButtonInterface|callable $smart_button The smart button script loading - * handler. - * @param Settings $settings The settings. - * @param DCCGatewayConfiguration $dcc_configuration The DCC gateway settings. - * @param Environment $environment The environment object. + * @param string $module_url The URL of this module. + * @param string $version The assets version. + * @param WC_Payment_Gateway $gateway Credit card gateway. + * @param SmartButtonInterface|callable $smart_button The smart button script loading handler. + * @param Settings $settings The settings. + * @param DCCGatewayConfiguration $dcc_configuration The DCC gateway settings. + * @param Environment $environment The environment object. * @param string $wcgateway_module_url The WcGateway module URL. + * @param array $payment_method_selected_map Mapping of payment methods to the PayPal Insights 'payment_method_selected' types. + * @param array $supported_country_card_type_matrix The supported country card type matrix for Axo. * @param array $enabled_shipping_locations The list of WooCommerce enabled shipping locations. */ public function __construct( - string $module_url, - string $version, - WC_Payment_Gateway $gateway, - $smart_button, - Settings $settings, - DCCGatewayConfiguration $dcc_configuration, - Environment $environment, - string $wcgateway_module_url, - array $enabled_shipping_locations + string $module_url, + string $version, + WC_Payment_Gateway $gateway, + $smart_button, + Settings $settings, + DCCGatewayConfiguration $dcc_configuration, + Environment $environment, + string $wcgateway_module_url, + array $payment_method_selected_map, + array $supported_country_card_type_matrix, + array $enabled_shipping_locations ) { - $this->name = AxoGateway::ID; - $this->module_url = $module_url; - $this->version = $version; - $this->gateway = $gateway; - $this->smart_button = $smart_button; - $this->settings = $settings; - $this->dcc_configuration = $dcc_configuration; - $this->environment = $environment; - $this->wcgateway_module_url = $wcgateway_module_url; - $this->enabled_shipping_locations = $enabled_shipping_locations; + $this->name = AxoGateway::ID; + $this->module_url = $module_url; + $this->version = $version; + $this->gateway = $gateway; + $this->smart_button = $smart_button; + $this->settings = $settings; + $this->dcc_configuration = $dcc_configuration; + $this->environment = $environment; + $this->wcgateway_module_url = $wcgateway_module_url; + $this->payment_method_selected_map = $payment_method_selected_map; + $this->supported_country_card_type_matrix = $supported_country_card_type_matrix; + $this->enabled_shipping_locations = $enabled_shipping_locations; } - /** * {@inheritDoc} */ @@ -203,19 +221,22 @@ class AxoBlockPaymentMethod extends AbstractPaymentMethodType { 'email' => 'render', ), 'insights' => array( - 'enabled' => defined( 'WP_DEBUG' ) && WP_DEBUG, - 'client_id' => ( $this->settings->has( 'client_id' ) ? $this->settings->get( 'client_id' ) : null ), - 'session_id' => + 'enabled' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'client_id' => ( $this->settings->has( 'client_id' ) ? $this->settings->get( 'client_id' ) : null ), + 'session_id' => ( WC()->session && method_exists( WC()->session, 'get_customer_unique_id' ) ) ? substr( md5( WC()->session->get_customer_unique_id() ), 0, 16 ) : '', - 'amount' => array( + 'amount' => array( 'currency_code' => get_woocommerce_currency(), 'value' => ( WC()->cart && method_exists( WC()->cart, 'get_total' ) ) ? WC()->cart->get_total( 'numeric' ) - : null, // Set to null if WC()->cart is null or get_total doesn't exist. + : null, ), + 'payment_method_selected_map' => $this->payment_method_selected_map, ), + 'allowed_cards' => $this->supported_country_card_type_matrix, + 'disable_cards' => $this->settings->has( 'disable_cards' ) ? (array) $this->settings->get( 'disable_cards' ) : array(), 'enabled_shipping_locations' => $this->enabled_shipping_locations, 'style_options' => array( 'root' => array( @@ -253,6 +274,8 @@ class AxoBlockPaymentMethod extends AbstractPaymentMethodType { ), 'logging_enabled' => $this->settings->has( 'logging_enabled' ) ? $this->settings->get( 'logging_enabled' ) : '', 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'card_icons' => $this->settings->has( 'card_icons' ) ? (array) $this->settings->get( 'card_icons' ) : array(), + 'merchant_country' => WC()->countries->get_base_country(), ); } } diff --git a/modules/ppcp-axo-block/webpack.config.js b/modules/ppcp-axo-block/webpack.config.js index b5db53234..86e2e2087 100644 --- a/modules/ppcp-axo-block/webpack.config.js +++ b/modules/ppcp-axo-block/webpack.config.js @@ -9,7 +9,10 @@ module.exports = { target: 'web', plugins: [ new DependencyExtractionWebpackPlugin() ], entry: { - 'index': path.resolve( './resources/js/index.js' ), + index: path.resolve( './resources/js/index.js' ), + PayPalInsightsLoader: path.resolve( + './resources/js/plugins/PayPalInsightsLoader.js' + ), gateway: path.resolve( './resources/css/gateway.scss' ), }, output: { diff --git a/modules/ppcp-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index de9fcff5c..e93fa9527 100644 --- a/modules/ppcp-axo/resources/js/AxoManager.js +++ b/modules/ppcp-axo/resources/js/AxoManager.js @@ -85,6 +85,8 @@ class AxoManager { }, }; + this.cardOptions = this.getCardOptions(); + this.enabledShippingLocations = this.axoConfig.enabled_shipping_locations; @@ -119,7 +121,7 @@ class AxoManager { this.axoConfig?.insights?.session_id ) { PayPalInsights.config( this.axoConfig?.insights?.client_id, { - debug: true, + debug: axoConfig?.wp_debug === '1', } ); PayPalInsights.setSessionId( this.axoConfig?.insights?.session_id ); PayPalInsights.trackJsLoad(); @@ -162,19 +164,25 @@ class AxoManager { } registerEventHandlers() { + // Payment method change tracking with duplicate prevention + let lastSelectedPaymentMethod = document.querySelector( + 'input[name=payment_method]:checked' + )?.value; this.$( document ).on( 'change', 'input[name=payment_method]', ( ev ) => { - const map = { - 'ppcp-axo-gateway': 'card', - 'ppcp-gateway': 'paypal', - }; - - PayPalInsights.trackSelectPaymentMethod( { - payment_method_selected: map[ ev.target.value ] || 'other', - page_type: 'checkout', - } ); + if ( lastSelectedPaymentMethod !== ev.target.value ) { + PayPalInsights.trackSelectPaymentMethod( { + payment_method_selected: + this.axoConfig?.insights + ?.payment_method_selected_map[ + ev.target.value + ] || 'other', + page_type: 'checkout', + } ); + lastSelectedPaymentMethod = ev.target.value; + } } ); @@ -664,6 +672,9 @@ class AxoManager { await this.fastlane.connect( { locale: this.locale, styles: this.styles, + cardOptions: { + allowedBrands: this.cardOptions, + }, shippingAddressOptions: { allowedLocations: this.enabledShippingLocations, }, @@ -1161,16 +1172,6 @@ class AxoManager { this.el.axoNonceInput.get().value = nonce; - PayPalInsights.trackEndCheckout( { - amount: this.axoConfig?.insights?.amount, - page_type: 'checkout', - payment_method_selected: 'card', - user_data: { - country: 'US', - is_store_member: false, - }, - } ); - if ( data ) { // Ryan flow. const form = document.querySelector( 'form.woocommerce-checkout' ); @@ -1251,6 +1252,31 @@ class AxoManager { return this.axoConfig?.widgets?.email === 'use_widget'; } + getCardOptions() { + const DEFAULT_ALLOWED_CARDS = [ + 'VISA', + 'MASTERCARD', + 'AMEX', + 'DISCOVER', + ]; + const merchantCountry = this.axoConfig.merchant_country || 'US'; + + const allowedCards = new Set( + this.axoConfig.allowed_cards?.[ merchantCountry ] || + DEFAULT_ALLOWED_CARDS + ); + + const disabledCards = new Set( + ( this.axoConfig.disable_cards || [] ).map( ( card ) => + card.toUpperCase() + ) + ); + + return [ ...allowedCards ].filter( + ( card ) => ! disabledCards.has( card ) + ); + } + deleteKeysWithEmptyString = ( obj ) => { for ( const key of Object.keys( obj ) ) { if ( obj[ key ] === '' ) { diff --git a/modules/ppcp-axo/resources/js/Insights/EndCheckoutTracker.js b/modules/ppcp-axo/resources/js/Insights/EndCheckoutTracker.js new file mode 100644 index 000000000..e21a9d0e4 --- /dev/null +++ b/modules/ppcp-axo/resources/js/Insights/EndCheckoutTracker.js @@ -0,0 +1,99 @@ +import PayPalInsights from '../../../../ppcp-axo/resources/js/Insights/PayPalInsights'; + +class EndCheckoutTracker { + constructor() { + this.initialize(); + } + + async initialize() { + const axoConfig = window.wc_ppcp_axo_insights_data || {}; + + if ( + axoConfig?.enabled === '1' && + axoConfig?.client_id && + axoConfig?.session_id && + axoConfig?.orderTotal && + axoConfig?.orderCurrency + ) { + try { + await this.waitForPayPalInsight(); + + PayPalInsights.config( axoConfig?.client_id, { + debug: axoConfig?.wp_debug === '1', + } ); + PayPalInsights.setSessionId( axoConfig.session_id ); + PayPalInsights.trackJsLoad(); + + const trackingData = { + amount: { + currency_code: axoConfig?.orderCurrency, + value: axoConfig?.orderTotal, + }, + page_type: 'checkout', + payment_method_selected: + axoConfig?.payment_method_selected_map[ + axoConfig?.paymentMethod + ] || 'other', + user_data: { + country: 'US', + is_store_member: false, + }, + order_id: axoConfig?.orderId, + order_key: axoConfig?.orderKey, + }; + + PayPalInsights.trackEndCheckout( trackingData ); + } catch ( error ) { + console.error( + 'EndCheckoutTracker: Error during tracking:', + error + ); + console.error( 'PayPalInsights object:', window.paypalInsight ); + } + } else { + console.warn( + 'EndCheckoutTracker: Missing required configuration', + { + enabled: axoConfig?.enabled, + hasClientId: !! axoConfig?.client_id, + hasSessionId: !! axoConfig?.session_id, + hasOrderTotal: !! axoConfig?.orderTotal, + hasOrderCurrency: !! axoConfig?.orderCurrency, + } + ); + } + } + + waitForPayPalInsight() { + return new Promise( ( resolve, reject ) => { + // If already loaded, resolve immediately + if ( window.paypalInsight ) { + resolve( window.paypalInsight ); + return; + } + + const timeoutId = setTimeout( () => { + observer.disconnect(); + reject( new Error( 'PayPal Insights script load timeout' ) ); + }, 10000 ); + + // Create MutationObserver to watch for script initialization + const observer = new MutationObserver( () => { + if ( window.paypalInsight ) { + observer.disconnect(); + clearTimeout( timeoutId ); + resolve( window.paypalInsight ); + } + } ); + + observer.observe( document, { + childList: true, + subtree: true, + } ); + } ); + } +} + +document.addEventListener( 'DOMContentLoaded', () => { + new EndCheckoutTracker(); +} ); diff --git a/modules/ppcp-axo/services.php b/modules/ppcp-axo/services.php index 4e7748032..e6767a29a 100644 --- a/modules/ppcp-axo/services.php +++ b/modules/ppcp-axo/services.php @@ -18,6 +18,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCGatewayConfiguration; +use WooCommerce\PayPalCommerce\ApiClient\Helper\CurrencyGetter; return array( @@ -64,10 +65,12 @@ return array( $container->get( 'session.handler' ), $container->get( 'wcgateway.settings' ), $container->get( 'onboarding.environment' ), + $container->get( 'axo.insights' ), $container->get( 'wcgateway.settings.status' ), $container->get( 'api.shop.currency.getter' ), $container->get( 'woocommerce.logger.woocommerce' ), $container->get( 'wcgateway.url' ), + $container->get( 'axo.supported-country-card-type-matrix' ), $container->get( 'axo.shipping-wc-enabled-locations' ) ); }, @@ -90,6 +93,55 @@ return array( ); }, + // Data needed for the PayPal Insights. + 'axo.insights' => static function ( ContainerInterface $container ): array { + $settings = $container->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + + $currency = $container->get( 'api.shop.currency.getter' ); + assert( $currency instanceof CurrencyGetter ); + + $session_id = ''; + if ( isset( WC()->session ) && method_exists( WC()->session, 'get_customer_unique_id' ) ) { + $session_id = substr( + md5( WC()->session->get_customer_unique_id() ), + 0, + 16 + ); + } + + return array( + 'enabled' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'client_id' => ( $settings->has( 'client_id' ) ? $settings->get( 'client_id' ) : null ), + 'session_id' => $session_id, + 'amount' => array( + 'currency_code' => $currency->get(), + ), + 'payment_method_selected_map' => $container->get( 'axo.payment_method_selected_map' ), + 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + ); + }, + + // The mapping of payment methods to the PayPal Insights 'payment_method_selected' types. + 'axo.payment_method_selected_map' => static function ( ContainerInterface $container ): array { + return array( + 'ppcp-axo-gateway' => 'card', + 'ppcp-credit-card-gateway' => 'card', + 'ppcp-gateway' => 'paypal', + 'ppcp-googlepay' => 'google_pay', + 'ppcp-applepay' => 'apple_pay', + 'ppcp-multibanco' => 'other', + 'ppcp-trustly' => 'other', + 'ppcp-p24' => 'other', + 'ppcp-mybank' => 'other', + 'ppcp-ideal' => 'other', + 'ppcp-eps' => 'other', + 'ppcp-blik' => 'other', + 'ppcp-bancontact' => 'other', + 'ppcp-card-button-gateway' => 'card', + ); + }, + /** * The matrix which countries and currency combinations can be used for AXO. */ @@ -111,7 +163,31 @@ return array( ) ); }, - + /** + * The matrix which countries and card type combinations can be used for AXO. + */ + 'axo.supported-country-card-type-matrix' => static function ( ContainerInterface $container ) : array { + /** + * Returns which countries and card type combinations can be used for AXO. + */ + return apply_filters( + 'woocommerce_paypal_payments_axo_supported_country_card_type_matrix', + array( + 'US' => array( + 'VISA', + 'MASTERCARD', + 'AMEX', + 'DISCOVER', + ), + 'CA' => array( + 'VISA', + 'MASTERCARD', + 'AMEX', + 'DISCOVER', + ), + ) + ); + }, 'axo.settings-conflict-notice' => static function ( ContainerInterface $container ) : string { $settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' ); assert( $settings_notice_generator instanceof SettingsNoticeGenerator ); diff --git a/modules/ppcp-axo/src/Assets/AxoManager.php b/modules/ppcp-axo/src/Assets/AxoManager.php index f0ad8b012..6fafcb681 100644 --- a/modules/ppcp-axo/src/Assets/AxoManager.php +++ b/modules/ppcp-axo/src/Assets/AxoManager.php @@ -29,28 +29,35 @@ class AxoManager { * * @var string */ - private $module_url; + private string $module_url; /** * The assets version. * * @var string */ - private $version; + private string $version; /** * The settings. * * @var Settings */ - private $settings; + private Settings $settings; /** * The environment object. * * @var Environment */ - private $environment; + private Environment $environment; + + /** + * Data needed for the PayPal Insights. + * + * @var array + */ + private array $insights_data; /** * The Settings status helper. @@ -71,22 +78,27 @@ class AxoManager { * * @var LoggerInterface */ - private $logger; + private LoggerInterface $logger; /** * Session handler. * * @var SessionHandler */ - private $session_handler; + private SessionHandler $session_handler; /** * The WcGateway module URL. * * @var string */ - private $wcgateway_module_url; - + private string $wcgateway_module_url; + /** + * The supported country card type matrix. + * + * @var array + */ + private array $supported_country_card_type_matrix; /** * The list of WooCommerce enabled shipping locations. * @@ -102,10 +114,12 @@ class AxoManager { * @param SessionHandler $session_handler The Session handler. * @param Settings $settings The Settings. * @param Environment $environment The environment object. + * @param array $insights_data Data needed for the PayPal Insights. * @param SettingsStatus $settings_status The Settings status helper. * @param CurrencyGetter $currency The getter of the 3-letter currency code of the shop. * @param LoggerInterface $logger The logger. * @param string $wcgateway_module_url The WcGateway module URL. + * @param array $supported_country_card_type_matrix The supported country card type matrix for Axo. * @param array $enabled_shipping_locations The list of WooCommerce enabled shipping locations. */ public function __construct( @@ -114,23 +128,27 @@ class AxoManager { SessionHandler $session_handler, Settings $settings, Environment $environment, + array $insights_data, SettingsStatus $settings_status, CurrencyGetter $currency, LoggerInterface $logger, string $wcgateway_module_url, + array $supported_country_card_type_matrix, array $enabled_shipping_locations ) { - $this->module_url = $module_url; - $this->version = $version; - $this->session_handler = $session_handler; - $this->settings = $settings; - $this->environment = $environment; - $this->settings_status = $settings_status; - $this->currency = $currency; - $this->logger = $logger; - $this->wcgateway_module_url = $wcgateway_module_url; - $this->enabled_shipping_locations = $enabled_shipping_locations; + $this->module_url = $module_url; + $this->version = $version; + $this->session_handler = $session_handler; + $this->settings = $settings; + $this->environment = $environment; + $this->insights_data = $insights_data; + $this->settings_status = $settings_status; + $this->currency = $currency; + $this->logger = $logger; + $this->wcgateway_module_url = $wcgateway_module_url; + $this->enabled_shipping_locations = $enabled_shipping_locations; + $this->supported_country_card_type_matrix = $supported_country_card_type_matrix; } /** @@ -171,7 +189,7 @@ class AxoManager { * * @return array */ - private function script_data() { + private function script_data(): array { return array( 'environment' => array( 'is_sandbox' => $this->environment->current_environment() === 'sandbox', @@ -179,20 +197,12 @@ class AxoManager { 'widgets' => array( 'email' => 'render', ), - 'insights' => array( - 'enabled' => defined( 'WP_DEBUG' ) && WP_DEBUG, - 'client_id' => ( $this->settings->has( 'client_id' ) ? $this->settings->get( 'client_id' ) : null ), - 'session_id' => - substr( - method_exists( WC()->session, 'get_customer_unique_id' ) ? md5( WC()->session->get_customer_unique_id() ) : '', - 0, - 16 - ), - 'amount' => array( - 'currency_code' => $this->currency->get(), - 'value' => WC()->cart->get_total( 'numeric' ), - ), - ), + // The amount is not available when setting the insights data, so we need to merge it here. + 'insights' => ( function( array $data ): array { + $data['amount']['value'] = WC()->cart->get_total( 'numeric' ); + return $data; } )( $this->insights_data ), + 'allowed_cards' => $this->supported_country_card_type_matrix, + 'disable_cards' => $this->settings->has( 'disable_cards' ) ? (array) $this->settings->get( 'disable_cards' ) : array(), 'enabled_shipping_locations' => $this->enabled_shipping_locations, 'style_options' => array( 'root' => array( @@ -231,6 +241,7 @@ class AxoManager { 'logging_enabled' => $this->settings->has( 'logging_enabled' ) ? $this->settings->get( 'logging_enabled' ) : '', 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'billing_email_button_text' => __( 'Continue', 'woocommerce-paypal-payments' ), + 'merchant_country' => WC()->countries->get_base_country(), ); } diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php index 512f61cb2..b055697ba 100644 --- a/modules/ppcp-axo/src/AxoModule.php +++ b/modules/ppcp-axo/src/AxoModule.php @@ -320,6 +320,15 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { $endpoint->handle_request(); } ); + + // Enqueue the PayPal Insights script. + add_action( + 'wp_enqueue_scripts', + function () use ( $c ) { + $this->enqueue_paypal_insights_script_on_order_received( $c ); + } + ); + return true; } @@ -377,11 +386,15 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { $dcc_configuration = $c->get( 'wcgateway.configuration.dcc' ); assert( $dcc_configuration instanceof DCCGatewayConfiguration ); + $subscription_helper = $c->get( 'wc-subscriptions.helper' ); + assert( $subscription_helper instanceof SubscriptionHelper ); + return ! is_user_logged_in() && CartCheckoutDetector::has_classic_checkout() && $dcc_configuration->use_fastlane() && ! $this->is_excluded_endpoint() - && is_checkout(); + && is_checkout() + && ! $subscription_helper->cart_contains_subscription(); } /** @@ -431,8 +444,8 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { * @return bool */ private function is_excluded_endpoint(): bool { - // Exclude the Order Pay endpoint. - return is_wc_endpoint_url( 'order-pay' ); + // Exclude the Order Pay and Order Received endpoints. + return is_wc_endpoint_url( 'order-pay' ) || is_wc_endpoint_url( 'order-received' ); } /** @@ -453,4 +466,57 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { $axo_enabled ? 'enabled' : 'disabled' ); } + + /** + * Enqueues PayPal Insights on the Order Received endpoint. + * + * @param ContainerInterface $c The service container. + * @return void + */ + private function enqueue_paypal_insights_script_on_order_received( ContainerInterface $c ): void { + global $wp; + + if ( ! isset( $wp->query_vars['order-received'] ) ) { + return; + } + + $order_id = absint( $wp->query_vars['order-received'] ); + if ( ! $order_id ) { + return; + } + + $order = wc_get_order( $order_id ); + if ( ! $order || ! $order instanceof \WC_Order ) { + return; + } + + $module_url = $c->get( 'axo.url' ); + $asset_version = $c->get( 'ppcp.asset-version' ); + $insights_data = $c->get( 'axo.insights' ); + + wp_register_script( + 'wc-ppcp-paypal-insights-end-checkout', + untrailingslashit( $module_url ) . '/assets/js/TrackEndCheckout.js', + array( 'wp-plugins', 'wp-data', 'wp-element', 'wc-blocks-registry' ), + $asset_version, + true + ); + + wp_localize_script( + 'wc-ppcp-paypal-insights-end-checkout', + 'wc_ppcp_axo_insights_data', + array_merge( + $insights_data, + array( + 'orderId' => $order_id, + 'orderTotal' => (string) $order->get_total(), + 'orderCurrency' => (string) $order->get_currency(), + 'paymentMethod' => (string) $order->get_payment_method(), + 'orderKey' => (string) $order->get_order_key(), + ) + ) + ); + + wp_enqueue_script( 'wc-ppcp-paypal-insights-end-checkout' ); + } } diff --git a/modules/ppcp-axo/webpack.config.js b/modules/ppcp-axo/webpack.config.js index 95c7f0fc6..e8638b564 100644 --- a/modules/ppcp-axo/webpack.config.js +++ b/modules/ppcp-axo/webpack.config.js @@ -1,39 +1,44 @@ -const path = require('path'); +const path = require( 'path' ); const isProduction = process.env.NODE_ENV === 'production'; const DependencyExtractionWebpackPlugin = require( '@woocommerce/dependency-extraction-webpack-plugin' ); module.exports = { - devtool: isProduction ? 'source-map' : 'eval-source-map', - mode: isProduction ? 'production' : 'development', - target: 'web', - plugins: [ new DependencyExtractionWebpackPlugin() ], - entry: { - 'boot': path.resolve('./resources/js/boot.js'), - 'styles': path.resolve('./resources/css/styles.scss') - }, - output: { - path: path.resolve(__dirname, 'assets/'), - filename: 'js/[name].js', - }, - module: { - rules: [{ - test: /\.js?$/, - exclude: /node_modules/, - loader: 'babel-loader', - }, - { - test: /\.scss$/, - exclude: /node_modules/, - use: [ - { - loader: 'file-loader', - options: { - name: 'css/[name].css', - } - }, - {loader:'sass-loader'} - ] - }] - } + devtool: isProduction ? 'source-map' : 'eval-source-map', + mode: isProduction ? 'production' : 'development', + target: 'web', + plugins: [ new DependencyExtractionWebpackPlugin() ], + entry: { + boot: path.resolve( './resources/js/boot.js' ), + styles: path.resolve( './resources/css/styles.scss' ), + TrackEndCheckout: path.resolve( + './resources/js/Insights/EndCheckoutTracker.js' + ), + }, + output: { + path: path.resolve( __dirname, 'assets/' ), + filename: 'js/[name].js', + }, + module: { + rules: [ + { + test: /\.js?$/, + exclude: /node_modules/, + loader: 'babel-loader', + }, + { + test: /\.scss$/, + exclude: /node_modules/, + use: [ + { + loader: 'file-loader', + options: { + name: 'css/[name].css', + }, + }, + { loader: 'sass-loader' }, + ], + }, + ], + }, }; diff --git a/modules/ppcp-blocks/resources/js/Components/card-fields.js b/modules/ppcp-blocks/resources/js/Components/card-fields.js index 05d41b315..f47b18f35 100644 --- a/modules/ppcp-blocks/resources/js/Components/card-fields.js +++ b/modules/ppcp-blocks/resources/js/Components/card-fields.js @@ -3,7 +3,10 @@ import { useEffect, useState } from '@wordpress/element'; import { PayPalScriptProvider, PayPalCardFieldsProvider, - PayPalCardFieldsForm, + PayPalNameField, + PayPalNumberField, + PayPalExpiryField, + PayPalCVVField, } from '@paypal/react-paypal-js'; import { CheckoutHandler } from './checkout-handler'; @@ -14,6 +17,7 @@ import { onApproveSavePayment, } from '../card-fields-config'; import { cartHasSubscriptionProducts } from '../Helper/Subscription'; +import { __ } from '@wordpress/i18n'; export function CardFields( { config, @@ -92,7 +96,16 @@ export function CardFields( { console.error( err ); } } > - + + +
+
+ +
+
+ +
+
{ - const {PaymentMethodIcons} = components; - return <> - - - -} +const Label = ( { components } ) => { + const { PaymentMethodIcons } = components; + return ( + <> + + + + ); +}; -registerPaymentMethod({ - name: config.id, - label: