diff --git a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js index 33d1ecfd3..d679a7f21 100644 --- a/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js +++ b/modules/ppcp-button/resources/js/modules/ContextBootstrap/CheckoutBootstap.js @@ -7,6 +7,10 @@ import { PaymentMethods, } from '../Helper/CheckoutMethodState'; import BootstrapHelper from '../Helper/BootstrapHelper'; +import { + ButtonEvents, + dispatchButtonEvent, +} from '../Helper/PaymentButtonHelpers'; class CheckoutBootstap { constructor( gateway, renderer, spinner, errorHandler ) { @@ -68,6 +72,7 @@ class CheckoutBootstap { jQuery( document.body ).on( 'updated_checkout payment_method_selected', () => { + this.invalidatePaymentMethods(); this.updateUi(); } ); @@ -174,6 +179,14 @@ class CheckoutBootstap { ); } + invalidatePaymentMethods() { + /** + * Custom JS event to notify other modules that the payment button on the checkout page + * has become irrelevant or invalid. + */ + dispatchButtonEvent( { event: ButtonEvents.INVALIDATE } ); + } + updateUi() { const currentPaymentMethod = getCurrentPaymentMethod(); const isPaypal = currentPaymentMethod === PaymentMethods.PAYPAL; @@ -232,9 +245,18 @@ class CheckoutBootstap { } } - setVisible( '#ppc-button-ppcp-googlepay', isGooglePayMethod ); + /** + * Custom JS event that is observed by the relevant payment gateway. + * + * Dynamic part of the event name is the payment method ID, for example + * "ppcp-credit-card-gateway" or "ppcp-googlepay" + */ + dispatchButtonEvent( { + event: ButtonEvents.RENDER, + paymentMethod: currentPaymentMethod, + } ); - jQuery( document.body ).trigger( 'ppcp_checkout_rendered' ); + document.body.dispatchEvent( new Event( 'ppcp_checkout_rendered' ) ); } shouldShowMessages() { diff --git a/modules/ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper.js b/modules/ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper.js index 3492462e7..7620547c0 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper.js +++ b/modules/ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper.js @@ -26,8 +26,11 @@ export function setupButtonEvents( refresh ) { document.addEventListener( REFRESH_BUTTON_EVENT, debouncedRefresh ); // Listen for cart and checkout update events. - document.body.addEventListener( 'updated_cart_totals', debouncedRefresh ); - document.body.addEventListener( 'updated_checkout', debouncedRefresh ); + // Note: we need jQuery here, because WooCommerce uses jQuery.trigger() to dispatch the events. + window + .jQuery( 'body' ) + .on( 'updated_cart_totals', debouncedRefresh ) + .on( 'updated_checkout', debouncedRefresh ); // Use setTimeout for fragment events to avoid unnecessary refresh on initial render. setTimeout( () => { diff --git a/modules/ppcp-button/resources/js/modules/Helper/CheckoutMethodState.js b/modules/ppcp-button/resources/js/modules/Helper/CheckoutMethodState.js index 3e284c8ef..41e952e42 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/CheckoutMethodState.js +++ b/modules/ppcp-button/resources/js/modules/Helper/CheckoutMethodState.js @@ -6,6 +6,30 @@ export const PaymentMethods = { GOOGLEPAY: 'ppcp-googlepay', }; +/** + * List of valid context values that the button can have. + * + * The "context" describes the placement or page where a payment button might be displayed. + * + * @type {Object} + */ +export const PaymentContext = { + Cart: 'cart', // Classic cart. + Checkout: 'checkout', // Classic checkout. + BlockCart: 'cart-block', // Block cart. + BlockCheckout: 'checkout-block', // Block checkout. + Product: 'product', // Single product page. + MiniCart: 'mini-cart', // Mini cart available on all pages except checkout & cart. + PayNow: 'pay-now', // Pay for order, via admin generated link. + Preview: 'preview', // Layout preview on settings page. + + // Contexts that use blocks to render payment methods. + Blocks: [ 'cart-block', 'checkout-block' ], + + // Contexts that display "classic" payment gateways. + Gateways: [ 'checkout', 'pay-now' ], +}; + export const ORDER_BUTTON_SELECTOR = '#place_order'; export const getCurrentPaymentMethod = () => { diff --git a/modules/ppcp-button/resources/js/modules/Helper/PaymentButtonHelpers.js b/modules/ppcp-button/resources/js/modules/Helper/PaymentButtonHelpers.js new file mode 100644 index 000000000..f9a066a23 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/PaymentButtonHelpers.js @@ -0,0 +1,117 @@ +/** + * Helper function used by PaymentButton instances. + * + * @file + */ + +/** + * Collection of recognized event names for payment button events. + * + * @type {Object} + */ +export const ButtonEvents = Object.freeze( { + INVALIDATE: 'ppcp_invalidate_methods', + RENDER: 'ppcp_render_method', + REDRAW: 'ppcp_redraw_method', +} ); + +/** + * + * @param {string} defaultId - Default wrapper ID. + * @param {string} miniCartId - Wrapper inside the mini-cart. + * @param {string} smartButtonId - ID of the smart button wrapper. + * @param {string} blockId - Block wrapper ID (express checkout, block cart). + * @param {string} gatewayId - Gateway wrapper ID (classic checkout). + * @return {{MiniCart, Gateway, Block, SmartButton, Default}} List of all wrapper IDs, by context. + */ +export function combineWrapperIds( + defaultId = '', + miniCartId = '', + smartButtonId = '', + blockId = '', + gatewayId = '' +) { + const sanitize = ( id ) => id.replace( /^#/, '' ); + + return { + Default: sanitize( defaultId ), + SmartButton: sanitize( smartButtonId ), + Block: sanitize( blockId ), + Gateway: sanitize( gatewayId ), + MiniCart: sanitize( miniCartId ), + }; +} + +/** + * Returns full payment button styles by combining the global ppcpConfig with + * payment-method-specific styling provided via buttonConfig. + * + * @param {Object} ppcpConfig - Global plugin configuration. + * @param {Object} buttonConfig - Payment method specific configuration. + * @return {{MiniCart: (*), Default: (*)}} Combined styles, separated by context. + */ +export function combineStyles( ppcpConfig, buttonConfig ) { + return { + Default: { + ...ppcpConfig.style, + ...buttonConfig.style, + }, + MiniCart: { + ...ppcpConfig.mini_cart_style, + ...buttonConfig.mini_cart_style, + }, + }; +} + +/** + * Verifies if the given event name is a valid Payment Button event. + * + * @param {string} event - The event name to verify. + * @return {boolean} True, if the event name is valid. + */ +export function isValidButtonEvent( event ) { + const buttonEventValues = Object.values( ButtonEvents ); + + return buttonEventValues.includes( event ); +} + +/** + * Dispatches a payment button event. + * + * @param {Object} options - The options for dispatching the event. + * @param {string} options.event - Event to dispatch. + * @param {string} [options.paymentMethod] - Optional. Name of payment method, to target a specific button only. + * @throws {Error} Throws an error if the event is invalid. + */ +export function dispatchButtonEvent( { event, paymentMethod = '' } ) { + if ( ! isValidButtonEvent( event ) ) { + throw new Error( `Invalid event: ${ event }` ); + } + + const fullEventName = paymentMethod + ? `${ event }-${ paymentMethod }` + : event; + + document.body.dispatchEvent( new Event( fullEventName ) ); +} + +/** + * Adds an event listener for the provided button event. + * + * @param {Object} options - The options for the event listener. + * @param {string} options.event - Event to observe. + * @param {string} [options.paymentMethod] - The payment method name (optional). + * @param {Function} options.callback - The callback function to execute when the event is triggered. + * @throws {Error} Throws an error if the event is invalid. + */ +export function observeButtonEvent( { event, paymentMethod = '', callback } ) { + if ( ! isValidButtonEvent( event ) ) { + throw new Error( `Invalid event: ${ event }` ); + } + + const fullEventName = paymentMethod + ? `${ event }-${ paymentMethod }` + : event; + + document.body.addEventListener( fullEventName, callback ); +} diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js new file mode 100644 index 000000000..11fe31f79 --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js @@ -0,0 +1,822 @@ +import ConsoleLogger from '../../../../../ppcp-wc-gateway/resources/js/helper/ConsoleLogger'; +import { apmButtonsInit } from '../Helper/ApmButtons'; +import { + getCurrentPaymentMethod, + PaymentContext, + PaymentMethods, +} from '../Helper/CheckoutMethodState'; +import { + ButtonEvents, + dispatchButtonEvent, + observeButtonEvent, +} from '../Helper/PaymentButtonHelpers'; + +/** + * Collection of all available styling options for this button. + * + * @typedef {Object} StylesCollection + * @property {string} Default - Default button styling. + * @property {string} MiniCart - Styles for mini-cart button. + */ + +/** + * Collection of all available wrapper IDs that are possible for the button. + * + * @typedef {Object} WrapperCollection + * @property {string} Default - Default button wrapper. + * @property {string} Gateway - Wrapper for separate gateway. + * @property {string} Block - Wrapper for block checkout button. + * @property {string} MiniCart - Wrapper for mini-cart button. + * @property {string} SmartButton - Wrapper for smart button container. + */ + +/** + * Adds the provided PaymentButton instance to a global payment-button collection. + * + * This is debugging logic that should not be used on a production site. + * + * @param {string} methodName - Used to group the buttons. + * @param {PaymentButton} button - Appended to the button collection. + */ +const addToDebuggingCollection = ( methodName, button ) => { + window.ppcpPaymentButtonList = window.ppcpPaymentButtonList || {}; + + const collection = window.ppcpPaymentButtonList; + + collection[ methodName ] = collection[ methodName ] || []; + collection[ methodName ].push( button ); +}; + +/** + * Provides a context-independent instance Map for `PaymentButton` components. + * + * This function addresses a potential issue in multi-context environments, such as pages using + * Block-components. In these scenarios, multiple React execution contexts can lead to duplicate + * `PaymentButton` instances. To prevent this, we store instances in a `Map` that is bound to the + * document's `body` (the rendering context) rather than to individual React components + * (execution contexts). + * + * The `Map` is created as a non-enumerable, non-writable, and non-configurable property of + * `document.body` to ensure its integrity and prevent accidental modifications. + * + * @return {Map} A Map containing all `PaymentButton` instances for the current page. + */ +const getInstances = () => { + const collectionKey = '__ppcpPBInstances'; + + if ( ! document.body[ collectionKey ] ) { + Object.defineProperty( document.body, collectionKey, { + value: new Map(), + enumerable: false, + writable: false, + configurable: false, + } ); + } + + return document.body[ collectionKey ]; +}; + +/** + * Base class for APM payment buttons, like GooglePay and ApplePay. + * + * This class is not intended for the PayPal button. + */ +export default class PaymentButton { + /** + * Defines the implemented payment method. + * + * Used to identify and address the button internally. + * Overwrite this in the derived class. + * + * @type {string} + */ + static methodId = 'generic'; + + /** + * CSS class that is added to the payment button wrapper. + * + * Overwrite this in the derived class. + * + * @type {string} + */ + static cssClass = ''; + + /** + * @type {ConsoleLogger} + */ + #logger; + + /** + * Whether the payment button is initialized. + * + * @type {boolean} + */ + #isInitialized = false; + + /** + * The button's context. + * + * @type {string} + */ + #context; + + /** + * Object containing the IDs of all possible wrapper elements that might contain this + * button; only one wrapper is relevant, depending on the value of the context. + * + * @type {Object} + */ + #wrappers; + + /** + * @type {StylesCollection} + */ + #styles; + + /** + * Keeps track of CSS classes that were added to the wrapper element. + * We use this list to remove CSS classes that we've added, e.g. to change shape from + * pill to rect in the preview. + * + * @type {string[]} + */ + #appliedClasses = []; + + /** + * APM relevant configuration; e.g., configuration of the GooglePay button. + */ + #buttonConfig; + + /** + * Plugin-wide configuration; i.e., PayPal client ID, shop currency, etc. + */ + #ppcpConfig; + + /** + * A variation of a context bootstrap handler. + */ + #externalHandler; + + /** + * A variation of a context handler object, like CheckoutHandler. + * This handler provides a standardized interface for certain standardized checks and actions. + */ + #contextHandler; + + /** + * Whether the current browser/website support the payment method. + * + * @type {boolean} + */ + #isEligible = false; + + /** + * Whether this button is visible. Modified by `show()` and `hide()` + * + * @type {boolean} + */ + #isVisible = true; + + /** + * The currently visible payment button. + * + * @see {PaymentButton.insertButton} + * @type {HTMLElement|null} + */ + #button = null; + + /** + * Factory method to create a new PaymentButton while limiting a single instance per context. + * + * @param {string} context - Button context name. + * @param {unknown} externalHandler - Handler object. + * @param {Object} buttonConfig - Payment button specific configuration. + * @param {Object} ppcpConfig - Plugin wide configuration object. + * @param {unknown} contextHandler - Handler object. + * @return {PaymentButton} The button instance. + */ + static createButton( + context, + externalHandler, + buttonConfig, + ppcpConfig, + contextHandler + ) { + const buttonInstances = getInstances(); + const instanceKey = `${ this.methodId }.${ context }`; + + if ( ! buttonInstances.has( instanceKey ) ) { + const button = new this( + context, + externalHandler, + buttonConfig, + ppcpConfig, + contextHandler + ); + + buttonInstances.set( instanceKey, button ); + } + + return buttonInstances.get( instanceKey ); + } + + /** + * Returns a list with all wrapper IDs for the implemented payment method, categorized by + * context. + * + * @abstract + * @param {Object} buttonConfig - Payment method specific configuration. + * @param {Object} ppcpConfig - Global plugin configuration. + * @return {{MiniCart, Gateway, Block, SmartButton, Default}} The wrapper ID collection. + */ + // eslint-disable-next-line no-unused-vars + static getWrappers( buttonConfig, ppcpConfig ) { + throw new Error( 'Must be implemented in the child class' ); + } + + /** + * Returns a list of all button styles for the implemented payment method, categorized by + * context. + * + * @abstract + * @param {Object} buttonConfig - Payment method specific configuration. + * @param {Object} ppcpConfig - Global plugin configuration. + * @return {{MiniCart: (*), Default: (*)}} Combined styles, separated by context. + */ + // eslint-disable-next-line no-unused-vars + static getStyles( buttonConfig, ppcpConfig ) { + throw new Error( 'Must be implemented in the child class' ); + } + + /** + * Initialize the payment button instance. + * + * Do not create new button instances directly; use the `createButton` method instead + * to avoid multiple button instances handling the same context. + * + * @private + * @param {string} context - Button context name. + * @param {Object} externalHandler - Handler object. + * @param {Object} buttonConfig - Payment button specific configuration. + * @param {Object} ppcpConfig - Plugin wide configuration object. + * @param {Object} contextHandler - Handler object. + */ + constructor( + context, + externalHandler = null, + buttonConfig = {}, + ppcpConfig = {}, + contextHandler = null + ) { + if ( this.methodId === PaymentButton.methodId ) { + throw new Error( 'Cannot initialize the PaymentButton base class' ); + } + + if ( ! buttonConfig ) { + buttonConfig = {}; + } + + const isDebugging = !! buttonConfig?.is_debug; + const methodName = this.methodId.replace( /^ppcp?-/, '' ); + + this.#context = context; + this.#buttonConfig = buttonConfig; + this.#ppcpConfig = ppcpConfig; + this.#externalHandler = externalHandler; + this.#contextHandler = contextHandler; + + this.#logger = new ConsoleLogger( methodName, context ); + + if ( isDebugging ) { + this.#logger.enabled = true; + addToDebuggingCollection( methodName, this ); + } + + this.#wrappers = this.constructor.getWrappers( + this.#buttonConfig, + this.#ppcpConfig + ); + this.applyButtonStyles( this.#buttonConfig ); + + apmButtonsInit( this.#ppcpConfig ); + this.initEventListeners(); + } + + /** + * Internal ID of the payment gateway. + * + * @readonly + * @return {string} The internal gateway ID, defined in the derived class. + */ + get methodId() { + return this.constructor.methodId; + } + + /** + * CSS class that is added to the button wrapper. + * + * @readonly + * @return {string} CSS class, defined in the derived class. + */ + get cssClass() { + return this.constructor.cssClass; + } + + /** + * Whether the payment button was fully initialized. + * + * @readonly + * @return {boolean} True indicates, that the button was fully initialized. + */ + get isInitialized() { + return this.#isInitialized; + } + + /** + * The button's context. + * + * TODO: Convert the string to a context-object (primitive obsession smell) + * + * @readonly + * @return {string} The button context. + */ + get context() { + return this.#context; + } + + /** + * Configuration, specific for the implemented payment button. + * + * @return {Object} Configuration object. + */ + get buttonConfig() { + return this.#buttonConfig; + } + + /** + * Plugin-wide configuration; i.e., PayPal client ID, shop currency, etc. + * + * @return {Object} Configuration object. + */ + get ppcpConfig() { + return this.#ppcpConfig; + } + + /** + * @return {Object} The bootstrap handler instance, or an empty object. + */ + get externalHandler() { + return this.#externalHandler || {}; + } + + /** + * Access the button's context handler. + * When no context handler was provided (like for a preview button), an empty object is + * returned. + * + * @return {Object} The context handler instance, or an empty object. + */ + get contextHandler() { + return this.#contextHandler || {}; + } + + /** + * Whether customers need to provide shipping details during payment. + * + * Can be extended by child classes to take method specific configuration into account. + * + * @return {boolean} True means, shipping fields are displayed and must be filled. + */ + get requiresShipping() { + // Default check: Is shipping enabled in WooCommerce? + return ( + 'function' === typeof this.contextHandler.shippingAllowed && + this.contextHandler.shippingAllowed() + ); + } + + /** + * Button wrapper details. + * + * @readonly + * @return {WrapperCollection} Wrapper IDs. + */ + get wrappers() { + return this.#wrappers; + } + + /** + * Returns the context-relevant button style object. + * + * @readonly + * @return {string} Styling options. + */ + get style() { + if ( PaymentContext.MiniCart === this.context ) { + return this.#styles.MiniCart; + } + + return this.#styles.Default; + } + + /** + * Returns the context-relevant wrapper ID. + * + * @readonly + * @return {string} The wrapper-element's ID (without the `#` prefix). + */ + get wrapperId() { + if ( PaymentContext.MiniCart === this.context ) { + return this.wrappers.MiniCart; + } else if ( this.isSeparateGateway ) { + return this.wrappers.Gateway; + } else if ( PaymentContext.Blocks.includes( this.context ) ) { + return this.wrappers.Block; + } + + return this.wrappers.Default; + } + + /** + * Determines if the current payment button should be rendered as a stand-alone gateway. + * The return value `false` usually means, that the payment button is bundled with all available + * payment buttons. + * + * The decision depends on the button context (placement) and the plugin settings. + * + * @return {boolean} True, if the current button represents a stand-alone gateway. + */ + get isSeparateGateway() { + return ( + this.#buttonConfig.is_wc_gateway_enabled && + PaymentContext.Gateways.includes( this.context ) + ); + } + + /** + * Whether the currently selected payment gateway is set to the payment method. + * + * Only relevant on checkout pages, when `this.isSeparateGateway` is true. + * + * @return {boolean} True means that this payment method is selected as current gateway. + */ + get isCurrentGateway() { + if ( ! this.isSeparateGateway ) { + return false; + } + + /* + * We need to rely on `getCurrentPaymentMethod()` here, as the `CheckoutBootstrap.js` + * module fires the "ButtonEvents.RENDER" event before any PaymentButton instances are + * created. I.e. we cannot observe the initial gateway selection event. + */ + return this.methodId === getCurrentPaymentMethod(); + } + + /** + * Flags a preview button without actual payment logic. + * + * @return {boolean} True indicates a preview instance that has no payment logic. + */ + get isPreview() { + return PaymentContext.Preview === this.context; + } + + /** + * Whether the browser can accept this payment method. + * + * @return {boolean} True, if payments are technically possible. + */ + get isEligible() { + return this.#isEligible; + } + + /** + * Changes the eligibility state of this button component. + * + * @param {boolean} newState Whether the browser can accept payments. + */ + set isEligible( newState ) { + if ( newState === this.#isEligible ) { + return; + } + + this.#isEligible = newState; + this.triggerRedraw(); + } + + /** + * The visibility state of the button. + * This flag does not reflect actual visibility on the page, but rather, if the button + * is intended/allowed to be displayed, in case all other checks pass. + * + * @return {boolean} True indicates, that the button can be displayed. + */ + get isVisible() { + return this.#isVisible; + } + + /** + * Change the visibility of the button. + * + * A visible button does not always force the button to render on the page. It only means, that + * the button is allowed or not allowed to render, if certain other conditions are met. + * + * @param {boolean} newState Whether rendering the button is allowed. + */ + set isVisible( newState ) { + if ( this.#isVisible === newState ) { + return; + } + + this.#isVisible = newState; + this.triggerRedraw(); + } + + /** + * Returns the HTML element that wraps the current button + * + * @readonly + * @return {HTMLElement|null} The wrapper element, or null. + */ + get wrapperElement() { + return document.getElementById( this.wrapperId ); + } + + /** + * Checks whether the main button-wrapper is present in the current DOM. + * + * @readonly + * @return {boolean} True, if the button context (wrapper element) is found. + */ + get isPresent() { + return this.wrapperElement instanceof HTMLElement; + } + + /** + * Checks, if the payment button is still attached to the DOM. + * + * WooCommerce performs some partial reloads in many cases, which can lead to our payment + * button + * to move into the browser's memory. In that case, we need to recreate the button in the + * updated DOM. + * + * @return {boolean} True means, the button is still present (and typically visible) on the + * page. + */ + get isButtonAttached() { + if ( ! this.#button ) { + return false; + } + + let parent = this.#button.parentElement; + while ( parent?.parentElement ) { + if ( 'BODY' === parent.tagName ) { + return true; + } + + parent = parent.parentElement; + } + + return false; + } + + /** + * Log a debug detail to the browser console. + * + * @param {any} args + */ + log( ...args ) { + this.#logger.log( ...args ); + } + + /** + * Log an error message to the browser console. + * + * @param {any} args + */ + error( ...args ) { + this.#logger.error( ...args ); + } + + /** + * Determines if the current button instance has valid and complete configuration details. + * Used during initialization to decide if the button can be initialized or should be skipped. + * + * Can be implemented by the derived class. + * + * @param {boolean} [silent=false] - Set to true to suppress console errors. + * @return {boolean} True indicates the config is valid and initialization can continue. + */ + validateConfiguration( silent = false ) { + return true; + } + + applyButtonStyles( buttonConfig, ppcpConfig = null ) { + if ( ! ppcpConfig ) { + ppcpConfig = this.ppcpConfig; + } + + this.#styles = this.constructor.getStyles( buttonConfig, ppcpConfig ); + + if ( this.isInitialized ) { + this.triggerRedraw(); + } + } + + /** + * Configures the button instance. Must be called before the initial `init()`. + * + * Parameters are defined by the derived class. + * + * @abstract + */ + configure() {} + + /** + * Must be named `init()` to simulate "protected" visibility: + * Since the derived class also implements a method with the same name, this method can only + * be called by the derived class, but not from any other code. + */ + init() { + this.#isInitialized = true; + } + + /** + * Must be named `reinit()` to simulate "protected" visibility: + * Since the derived class also implements a method with the same name, this method can only + * be called by the derived class, but not from any other code. + */ + reinit() { + this.#isInitialized = false; + this.#isEligible = false; + } + + triggerRedraw() { + this.showPaymentGateway(); + + dispatchButtonEvent( { + event: ButtonEvents.REDRAW, + paymentMethod: this.methodId, + } ); + } + + /** + * Attaches event listeners to show or hide the payment button when needed. + */ + initEventListeners() { + // Refresh the button - this might show, hide or re-create the payment button. + observeButtonEvent( { + event: ButtonEvents.REDRAW, + paymentMethod: this.methodId, + callback: () => this.refresh(), + } ); + + // Events relevant for buttons inside a payment gateway. + if ( PaymentContext.Gateways.includes( this.context ) ) { + const parentMethod = this.isSeparateGateway + ? this.methodId + : PaymentMethods.PAYPAL; + + // Hide the button right after the user selected _any_ gateway. + observeButtonEvent( { + event: ButtonEvents.INVALIDATE, + callback: () => ( this.isVisible = false ), + } ); + + // Show the button (again) when the user selected the current gateway. + observeButtonEvent( { + event: ButtonEvents.RENDER, + paymentMethod: parentMethod, + callback: () => ( this.isVisible = true ), + } ); + } + } + + /** + * Refreshes the payment button on the page. + */ + refresh() { + if ( ! this.isPresent ) { + return; + } + + this.applyWrapperStyles(); + + if ( this.isEligible && this.isPresent && this.isVisible ) { + if ( ! this.isButtonAttached ) { + this.log( 'refresh.addButton' ); + this.addButton(); + } + } + } + + /** + * Makes the custom payment gateway visible by removing initial inline styles from the DOM. + * + * Only relevant on the checkout page, i.e., when `this.isSeparateGateway` is `true` + */ + showPaymentGateway() { + if ( ! this.isSeparateGateway || ! this.isEligible ) { + return; + } + + const styleSelectors = `style[data-hide-gateway="${ this.methodId }"]`; + + const styles = document.querySelectorAll( styleSelectors ); + + if ( ! styles.length ) { + return; + } + this.log( 'Show gateway' ); + + styles.forEach( ( el ) => el.remove() ); + + // This code runs only once, during button initialization, and fixes the initial visibility. + this.isVisible = this.isCurrentGateway; + } + + /** + * Applies CSS classes and inline styling to the payment button wrapper. + */ + applyWrapperStyles() { + const wrapper = this.wrapperElement; + const { shape, height } = this.style; + + for ( const classItem of this.#appliedClasses ) { + wrapper.classList.remove( classItem ); + } + + this.#appliedClasses = []; + + const newClasses = [ + `ppcp-button-${ shape }`, + 'ppcp-button-apm', + this.cssClass, + ]; + + wrapper.classList.add( ...newClasses ); + this.#appliedClasses.push( ...newClasses ); + + if ( height ) { + wrapper.style.height = `${ height }px`; + } + + // Apply the wrapper visibility. + wrapper.style.display = this.isVisible ? 'block' : 'none'; + } + + /** + * Creates a new payment button (HTMLElement) and must call `this.insertButton()` to display + * that button in the correct wrapper. + * + * @abstract + */ + addButton() { + throw new Error( 'Must be implemented by the child class' ); + } + + /** + * Prepares the button wrapper element and inserts the provided payment button into the DOM. + * + * If a payment button was previously inserted to the wrapper, calling this method again will + * first remove the previous button. + * + * @param {HTMLElement} button - The button element to inject. + */ + insertButton( button ) { + if ( ! this.isPresent ) { + return; + } + + const wrapper = this.wrapperElement; + + if ( this.#button ) { + this.removeButton(); + } + + this.log( 'addButton', button ); + + this.#button = button; + wrapper.appendChild( this.#button ); + } + + /** + * Removes the payment button from the DOM. + */ + removeButton() { + if ( ! this.isPresent || ! this.#button ) { + return; + } + + this.log( 'removeButton' ); + + try { + this.wrapperElement.removeChild( this.#button ); + } catch ( Exception ) { + // Ignore this. + } + + this.#button = null; + } +} diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js b/modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js index 42e715904..1ea2f04bc 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js @@ -1,11 +1,17 @@ import { loadCustomScript } from '@paypal/paypal-js'; import widgetBuilder from './WidgetBuilder'; import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce'; +import ConsoleLogger from '../../../../../ppcp-wc-gateway/resources/js/helper/ConsoleLogger'; /** * Manages all PreviewButton instances of a certain payment method on the page. */ class PreviewButtonManager { + /** + * @type {ConsoleLogger} + */ + #logger; + /** * Resolves the promise. * Used by `this.boostrap()` to process enqueued initialization logic. @@ -32,6 +38,9 @@ class PreviewButtonManager { this.apiConfig = null; this.apiError = ''; + this.#logger = new ConsoleLogger( this.methodName, 'preview-manager' ); + this.#logger.enabled = true; // Manually set this to true for development. + this.#onInit = new Promise( ( resolve ) => { this.#onInitResolver = resolve; } ); @@ -61,9 +70,11 @@ class PreviewButtonManager { * Responsible for fetching and returning the PayPal configuration object for this payment * method. * + * @abstract * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. * @return {Promise<{}>} */ + // eslint-disable-next-line no-unused-vars async fetchConfig( payPal ) { throw new Error( 'The "fetchConfig" method must be implemented by the derived class' @@ -74,9 +85,11 @@ class PreviewButtonManager { * Protected method that needs to be implemented by the derived class. * This method is responsible for creating a new PreviewButton instance and returning it. * + * @abstract * @param {string} wrapperId - CSS ID of the wrapper element. * @return {PreviewButton} */ + // eslint-disable-next-line no-unused-vars createButtonInstance( wrapperId ) { throw new Error( 'The "createButtonInstance" method must be implemented by the derived class' @@ -90,7 +103,7 @@ class PreviewButtonManager { * This dummy is only visible on the admin side, and not rendered on the front-end. * * @todo Consider refactoring this into a new class that extends the PreviewButton class. - * @param wrapperId + * @param {string} wrapperId * @return {any} */ createDummy( wrapperId ) { @@ -128,13 +141,24 @@ class PreviewButtonManager { ); } + /** + * Output a debug message to the console, with a module-specific prefix. + * + * @param {string} message - Log message. + * @param {...any} args - Optional. Additional args to output. + */ + log( message, ...args ) { + this.#logger.log( message, ...args ); + } + /** * Output an error message to the console, with a module-specific prefix. - * @param message - * @param {...any} args + * + * @param {string} message - Log message. + * @param {...any} args - Optional. Additional args to output. */ error( message, ...args ) { - console.error( `${ this.methodName } ${ message }`, ...args ); + this.#logger.error( message, ...args ); } /** @@ -242,21 +266,21 @@ class PreviewButtonManager { } if ( ! this.shouldInsertPreviewButton( id ) ) { + this.log( 'Skip preview rendering for this preview-box', id ); return; } if ( ! this.buttons[ id ] ) { this._addButton( id, ppcpConfig ); } else { - // This is a debounced method, that fires after 100ms. - this._configureAllButtons( ppcpConfig ); + this._configureButton( id, ppcpConfig ); } } /** * Determines if the preview box supports the current button. * - * When this function returns false, this manager instance does not create a new preview button. + * E.g. "Should the current preview-box display Google Pay buttons?" * * @param {string} previewId - ID of the inner preview box container. * @return {boolean} True if the box is eligible for the preview button, false otherwise. @@ -271,10 +295,14 @@ class PreviewButtonManager { /** * Applies a new configuration to an existing preview button. + * + * @private * @param id * @param ppcpConfig */ _configureButton( id, ppcpConfig ) { + this.log( 'configureButton', id, ppcpConfig ); + this.buttons[ id ] .setDynamic( this.isDynamic() ) .setPpcpConfig( ppcpConfig ) @@ -283,9 +311,13 @@ class PreviewButtonManager { /** * Apples the provided configuration to all existing preview buttons. - * @param ppcpConfig + * + * @private + * @param ppcpConfig - The new styling to use for the preview buttons. */ _configureAllButtons( ppcpConfig ) { + this.log( 'configureAllButtons', ppcpConfig ); + Object.entries( this.buttons ).forEach( ( [ id, button ] ) => { this._configureButton( id, { ...ppcpConfig, @@ -302,13 +334,20 @@ class PreviewButtonManager { /** * Creates a new preview button, that is rendered once the bootstrapping Promise resolves. - * @param id - * @param ppcpConfig + * + * @private + * @param id - The button to add. + * @param ppcpConfig - The styling to apply to the preview button. */ _addButton( id, ppcpConfig ) { + this.log( 'addButton', id, ppcpConfig ); + const createButton = () => { if ( ! this.buttons[ id ] ) { + this.log( 'createButton.new', id ); + let newInst; + if ( this.apiConfig && 'object' === typeof this.apiConfig ) { newInst = this.createButtonInstance( id ).setButtonConfig( this.buttonConfig diff --git a/modules/ppcp-googlepay/resources/css/styles.scss b/modules/ppcp-googlepay/resources/css/styles.scss index 6cf2119a0..93cb05f47 100644 --- a/modules/ppcp-googlepay/resources/css/styles.scss +++ b/modules/ppcp-googlepay/resources/css/styles.scss @@ -1,3 +1,10 @@ +/* Front end display */ +.ppcp-button-apm .gpay-card-info-container-fill .gpay-card-info-container { + outline-offset: -1px; + border-radius: var(--apm-button-border-radius); +} + +/* Admin preview */ .ppcp-button-googlepay { min-height: 40px; diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index ac477404b..4b267c4e7 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -1,10 +1,120 @@ -import { setVisible } from '../../../ppcp-button/resources/js/modules/Helper/Hiding'; -import { setEnabled } from '../../../ppcp-button/resources/js/modules/Helper/ButtonDisabler'; +import { + combineStyles, + combineWrapperIds, +} from '../../../ppcp-button/resources/js/modules/Helper/PaymentButtonHelpers'; +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 { apmButtonsInit } from '../../../ppcp-button/resources/js/modules/Helper/ApmButtons'; +import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper/CheckoutMethodState'; + +/** + * Plugin-specific styling. + * + * Note that most properties of this object do not apply to the Google Pay button. + * + * @typedef {Object} PPCPStyle + * @property {string} shape - Outline shape. + * @property {?number} height - Button height in pixel. + */ + +/** + * Style options that are defined by the Google Pay SDK and are required to render the button. + * + * @typedef {Object} GooglePayStyle + * @property {string} type - Defines the button label. + * @property {string} color - Button color + * @property {string} language - The locale; an empty string will apply the user-agent's language. + */ + +/** + * Google Pay JS SDK + * + * @see https://developers.google.com/pay/api/web/reference/request-objects + * @typedef {Object} GooglePaySDK + * @property {typeof PaymentsClient} PaymentsClient - Main API client for payment actions. + */ + +/** + * The Payments Client class, generated by the Google Pay SDK. + * + * @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 {Function} 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. + */ + +/** + * This object describes the transaction details. + * + * @see https://developers.google.com/pay/api/web/reference/request-objects#TransactionInfo + * @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. + */ + +class GooglepayButton extends PaymentButton { + /** + * @inheritDoc + */ + static methodId = PaymentMethods.GOOGLEPAY; + + /** + * @inheritDoc + */ + static cssClass = 'google-pay'; + + /** + * Client reference, provided by the Google Pay JS SDK. + */ + #paymentsClient = null; + + /** + * Details about the processed transaction. + * + * @type {?TransactionInfo} + */ + #transactionInfo = null; + + googlePayConfig = null; + + /** + * @inheritDoc + */ + static getWrappers( buttonConfig, ppcpConfig ) { + return combineWrapperIds( + buttonConfig?.button?.wrapper || '', + buttonConfig?.button?.mini_cart_wrapper || '', + ppcpConfig?.button?.wrapper || '', + 'ppc-button-googlepay-container', + 'ppc-button-ppcp-googlepay' + ); + } + + /** + * @inheritDoc + */ + static getStyles( buttonConfig, ppcpConfig ) { + const styles = combineStyles( + ppcpConfig?.button || {}, + buttonConfig?.button || {} + ); + + if ( 'buy' === styles.MiniCart.type ) { + styles.MiniCart.type = 'pay'; + } + + return styles; + } -class GooglepayButton { constructor( context, externalHandler, @@ -12,274 +122,257 @@ class GooglepayButton { ppcpConfig, contextHandler ) { - apmButtonsInit( ppcpConfig ); + // Disable debug output in the browser console: + // buttonConfig.is_debug = false; - this.isInitialized = false; + super( + context, + externalHandler, + buttonConfig, + ppcpConfig, + contextHandler + ); - this.context = context; - this.externalHandler = externalHandler; - this.buttonConfig = buttonConfig; - this.ppcpConfig = ppcpConfig; - this.contextHandler = contextHandler; + this.init = this.init.bind( this ); + this.onPaymentAuthorized = this.onPaymentAuthorized.bind( this ); + this.onPaymentDataChanged = this.onPaymentDataChanged.bind( this ); + this.onButtonClick = this.onButtonClick.bind( this ); - this.paymentsClient = null; + this.log( 'Create instance' ); + } - this.log = function () { - if ( this.buttonConfig.is_debug ) { - //console.log('[GooglePayButton]', ...arguments); + /** + * @inheritDoc + */ + get requiresShipping() { + return super.requiresShipping && this.buttonConfig.shipping?.enabled; + } + + /** + * The Google Pay API. + * + * @return {?GooglePaySDK} API for the Google Pay JS SDK, or null when SDK is not ready yet. + */ + get googlePayApi() { + return window.google?.payments?.api; + } + + /** + * The Google Pay PaymentsClient instance created by this button. + * @see https://developers.google.com/pay/api/web/reference/client + * + * @return {?PaymentsClient} The SDK object, or null when SDK is not ready yet. + */ + get paymentsClient() { + return this.#paymentsClient; + } + + /** + * Details about the processed transaction. + * + * This object defines the price that is charged, and text that is displayed inside the + * payment sheet. + * + * @return {?TransactionInfo} The TransactionInfo object. + */ + get transactionInfo() { + return this.#transactionInfo; + } + + /** + * Assign the new transaction details to the payment button. + * + * @param {TransactionInfo} newTransactionInfo - Transaction details. + */ + set transactionInfo( newTransactionInfo ) { + this.#transactionInfo = newTransactionInfo; + + this.refresh(); + } + + /** + * @inheritDoc + */ + validateConfiguration( silent = false ) { + const validEnvs = [ 'PRODUCTION', 'TEST' ]; + + const isInvalid = ( ...args ) => { + if ( ! silent ) { + this.error( ...args ); } + return false; }; - } - init( config, transactionInfo ) { - if ( this.isInitialized ) { - return; - } - this.isInitialized = true; - - if ( ! this.validateConfig() ) { - return; + if ( ! validEnvs.includes( this.buttonConfig.environment ) ) { + return isInvalid( + 'Invalid environment:', + this.buttonConfig.environment + ); } - if ( ! this.contextHandler.validateContext() ) { - return; + // Preview buttons only need a valid environment. + if ( this.isPreview ) { + return true; } - this.googlePayConfig = config; - this.transactionInfo = transactionInfo; - this.allowedPaymentMethods = config.allowedPaymentMethods; - this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ]; - - this.initClient(); - this.initEventHandlers(); - - this.paymentsClient - .isReadyToPay( - this.buildReadyToPayRequest( - this.allowedPaymentMethods, - config - ) - ) - .then( ( response ) => { - if ( response.result ) { - if ( - ( this.context === 'checkout' || - this.context === 'pay-now' ) && - this.buttonConfig.is_wc_gateway_enabled === '1' - ) { - const wrapper = document.getElementById( - 'ppc-button-ppcp-googlepay' - ); - - if ( wrapper ) { - const { ppcpStyle, buttonStyle } = - this.contextConfig(); - - wrapper.classList.add( - `ppcp-button-${ ppcpStyle.shape }`, - 'ppcp-button-apm', - 'ppcp-button-googlepay' - ); - - if ( ppcpStyle.height ) { - wrapper.style.height = `${ ppcpStyle.height }px`; - } - - this.addButtonCheckout( - this.baseCardPaymentMethod, - wrapper, - buttonStyle - ); - - return; - } - } - - this.addButton( this.baseCardPaymentMethod ); - } - } ) - .catch( function ( err ) { - console.error( err ); - } ); - } - - reinit() { if ( ! this.googlePayConfig ) { - return; + return isInvalid( + 'No API configuration - missing configure() call?' + ); } - this.isInitialized = false; - this.init( this.googlePayConfig, this.transactionInfo ); - } - - validateConfig() { - if ( - [ 'PRODUCTION', 'TEST' ].indexOf( - this.buttonConfig.environment - ) === -1 - ) { - console.error( - '[GooglePayButton] Invalid environment.', - this.buttonConfig.environment + if ( ! this.transactionInfo ) { + return isInvalid( + 'No transactionInfo - missing configure() call?' ); - return false; } - if ( ! this.contextHandler ) { - console.error( - '[GooglePayButton] Invalid context handler.', - this.contextHandler - ); - return false; + if ( ! typeof this.contextHandler?.validateContext() ) { + return isInvalid( 'Invalid context handler.', this.contextHandler ); } return true; } /** - * Returns configurations relative to this button context. + * Configures the button instance. Must be called before the initial `init()`. + * + * @param {Object} apiConfig - API configuration. + * @param {Object} transactionInfo - Transaction details; required before "init" call. */ - contextConfig() { - const config = { - wrapper: this.buttonConfig.button.wrapper, - ppcpStyle: this.ppcpConfig.button.style, - buttonStyle: this.buttonConfig.button.style, - ppcpButtonWrapper: this.ppcpConfig.button.wrapper, - }; + configure( apiConfig, transactionInfo ) { + this.googlePayConfig = apiConfig; + this.#transactionInfo = transactionInfo; - if ( this.context === 'mini-cart' ) { - config.wrapper = this.buttonConfig.button.mini_cart_wrapper; - config.ppcpStyle = this.ppcpConfig.button.mini_cart_style; - config.buttonStyle = this.buttonConfig.button.mini_cart_style; - config.ppcpButtonWrapper = this.ppcpConfig.button.mini_cart_wrapper; - - // Handle incompatible types. - if ( config.buttonStyle.type === 'buy' ) { - config.buttonStyle.type = 'pay'; - } - } - - if ( - [ 'cart-block', 'checkout-block' ].indexOf( this.context ) !== -1 - ) { - config.ppcpButtonWrapper = - '#express-payment-method-ppcp-gateway-paypal'; - } - - return config; + this.allowedPaymentMethods = this.googlePayConfig.allowedPaymentMethods; + this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ]; } - initClient() { - const callbacks = { - onPaymentAuthorized: this.onPaymentAuthorized.bind( this ), - }; - - if ( - this.buttonConfig.shipping.enabled && - this.contextHandler.shippingAllowed() - ) { - callbacks.onPaymentDataChanged = - this.onPaymentDataChanged.bind( this ); + init() { + // Use `reinit()` to force a full refresh of an initialized button. + if ( this.isInitialized ) { + return; } - this.paymentsClient = new google.payments.api.PaymentsClient( { + // Stop, if configuration is invalid. + if ( ! this.validateConfiguration() ) { + return; + } + + super.init(); + this.#paymentsClient = this.createPaymentsClient(); + + if ( ! this.isPresent ) { + this.log( 'Payment wrapper not found', this.wrapperId ); + return; + } + + if ( ! this.paymentsClient ) { + this.log( 'Could not initialize the payments client' ); + return; + } + + this.paymentsClient + .isReadyToPay( + this.buildReadyToPayRequest( + this.allowedPaymentMethods, + this.googlePayConfig + ) + ) + .then( ( response ) => { + this.log( 'PaymentsClient.isReadyToPay response:', response ); + this.isEligible = !! response.result; + } ) + .catch( ( err ) => { + this.error( err ); + this.isEligible = false; + } ); + } + + reinit() { + // Missing (invalid) configuration indicates, that the first `init()` call did not happen yet. + if ( ! this.validateConfiguration( true ) ) { + return; + } + + super.reinit(); + + this.init(); + } + + /** + * Provides an object with relevant paymentDataCallbacks for the current button instance. + * + * @return {Object} An object containing callbacks for the current scope & configuration. + */ + preparePaymentDataCallbacks() { + const callbacks = {}; + + // We do not attach any callbacks to preview buttons. + if ( this.isPreview ) { + return callbacks; + } + + callbacks.onPaymentAuthorized = this.onPaymentAuthorized; + + if ( this.requiresShipping ) { + callbacks.onPaymentDataChanged = this.onPaymentDataChanged; + } + + return callbacks; + } + + createPaymentsClient() { + if ( ! this.googlePayApi ) { + return null; + } + + const callbacks = this.preparePaymentDataCallbacks(); + + /** + * Consider providing merchant info here: + * + * @see https://developers.google.com/pay/api/web/reference/request-objects#PaymentOptions + */ + return new this.googlePayApi.PaymentsClient( { environment: this.buttonConfig.environment, - // add merchant info maybe paymentDataCallbacks: callbacks, } ); } - initEventHandlers() { - const { wrapper, ppcpButtonWrapper } = this.contextConfig(); - - if ( wrapper === ppcpButtonWrapper ) { - throw new Error( - `[GooglePayButton] "wrapper" and "ppcpButtonWrapper" values must differ to avoid infinite loop. Current value: "${ wrapper }"` - ); - } - - const syncButtonVisibility = () => { - const $ppcpButtonWrapper = jQuery( ppcpButtonWrapper ); - setVisible( wrapper, $ppcpButtonWrapper.is( ':visible' ) ); - setEnabled( - wrapper, - ! $ppcpButtonWrapper.hasClass( 'ppcp-disabled' ) - ); - }; - - jQuery( document ).on( - 'ppcp-shown ppcp-hidden ppcp-enabled ppcp-disabled', - ( ev, data ) => { - if ( jQuery( data.selector ).is( ppcpButtonWrapper ) ) { - syncButtonVisibility(); - } - } - ); - - syncButtonVisibility(); - } - buildReadyToPayRequest( allowedPaymentMethods, baseRequest ) { + this.log( 'Ready To Pay request', baseRequest, allowedPaymentMethods ); + return Object.assign( {}, baseRequest, { allowedPaymentMethods, } ); } /** - * Add a Google Pay purchase button - * @param baseCardPaymentMethod + * Creates the payment button and calls `this.insertButton()` to make the button visible in the + * correct wrapper. */ - addButton( baseCardPaymentMethod ) { - this.log( 'addButton', this.context ); + addButton() { + if ( ! this.paymentsClient ) { + return; + } - const { wrapper, ppcpStyle, buttonStyle } = this.contextConfig(); + const baseCardPaymentMethod = this.baseCardPaymentMethod; + const { color, type, language } = this.style; - this.waitForWrapper( wrapper, () => { - jQuery( wrapper ).addClass( 'ppcp-button-' + ppcpStyle.shape ); - - if ( ppcpStyle.height ) { - jQuery( wrapper ).css( 'height', `${ ppcpStyle.height }px` ); - } - - const button = this.paymentsClient.createButton( { - onClick: this.onButtonClick.bind( this ), - allowedPaymentMethods: [ baseCardPaymentMethod ], - buttonColor: buttonStyle.color || 'black', - buttonType: buttonStyle.type || 'pay', - buttonLocale: buttonStyle.language || 'en', - buttonSizeMode: 'fill', - } ); - - jQuery( wrapper ).append( button ); - } ); - } - - addButtonCheckout( baseCardPaymentMethod, wrapper, buttonStyle ) { + /** + * @see https://developers.google.com/pay/api/web/reference/client#createButton + */ const button = this.paymentsClient.createButton( { - onClick: this.onButtonClick.bind( this ), + onClick: this.onButtonClick, allowedPaymentMethods: [ baseCardPaymentMethod ], - buttonColor: buttonStyle.color || 'black', - buttonType: buttonStyle.type || 'pay', - buttonLocale: buttonStyle.language || 'en', + buttonColor: color || 'black', + buttonType: type || 'pay', + buttonLocale: language || 'en', buttonSizeMode: 'fill', } ); - wrapper.appendChild( button ); - } - - waitForWrapper( selector, callback, delay = 100, timeout = 2000 ) { - const startTime = Date.now(); - const interval = setInterval( () => { - const el = document.querySelector( selector ); - const timeElapsed = Date.now() - startTime; - - if ( el ) { - clearInterval( interval ); - callback( el ); - } else if ( timeElapsed > timeout ) { - clearInterval( interval ); - } - }, delay ); + this.insertButton( button ); } //------------------------ @@ -290,35 +383,49 @@ class GooglepayButton { * Show Google Pay payment sheet when Google Pay payment button is clicked */ onButtonClick() { - this.log( 'onButtonClick', this.context ); + this.log( 'onButtonClick' ); const initiatePaymentRequest = () => { window.ppcpFundingSource = 'googlepay'; - const paymentDataRequest = this.paymentDataRequest(); - this.log( 'onButtonClick: paymentDataRequest', paymentDataRequest, this.context ); - this.paymentsClient.loadPaymentData( paymentDataRequest ); }; - if ( 'function' === typeof this.contextHandler.validateForm ) { - // During regular checkout, validate the checkout form before initiating the payment. - this.contextHandler - .validateForm() - .then( initiatePaymentRequest, () => { - console.error( - '[GooglePayButton] Form validation failed.' - ); + const validateForm = () => { + if ( 'function' !== typeof this.contextHandler.validateForm ) { + return Promise.resolve(); + } + + return this.contextHandler.validateForm().catch( ( error ) => { + this.error( 'Form validation failed:', error ); + throw error; + } ); + }; + + const getTransactionInfo = () => { + if ( 'function' !== typeof this.contextHandler.transactionInfo ) { + return Promise.resolve(); + } + + return this.contextHandler + .transactionInfo() + .then( ( transactionInfo ) => { + this.transactionInfo = transactionInfo; + } ) + .catch( ( error ) => { + this.error( 'Failed to get transaction info:', error ); + throw error; } ); - } else { - // This is the flow on product page, cart, and other non-checkout pages. - initiatePaymentRequest(); - } + }; + + validateForm() + .then( getTransactionInfo ) + .then( initiatePaymentRequest ); } paymentDataRequest() { @@ -334,10 +441,7 @@ class GooglepayButton { paymentDataRequest.transactionInfo = this.transactionInfo; paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo; - if ( - this.buttonConfig.shipping.enabled && - this.contextHandler.shippingAllowed() - ) { + if ( this.requiresShipping ) { paymentDataRequest.callbackIntents = [ 'SHIPPING_ADDRESS', 'SHIPPING_OPTION', @@ -366,8 +470,7 @@ class GooglepayButton { } onPaymentDataChanged( paymentData ) { - this.log( 'onPaymentDataChanged', this.context ); - this.log( 'paymentData', paymentData ); + this.log( 'onPaymentDataChanged', paymentData ); return new Promise( async ( resolve, reject ) => { try { @@ -412,7 +515,7 @@ class GooglepayButton { resolve( paymentDataRequestUpdate ); } catch ( error ) { - console.error( 'Error during onPaymentDataChanged:', error ); + this.error( 'Error during onPaymentDataChanged:', error ); reject( error ); } } ); @@ -440,18 +543,18 @@ class GooglepayButton { //------------------------ onPaymentAuthorized( paymentData ) { - this.log( 'onPaymentAuthorized', this.context ); + this.log( 'onPaymentAuthorized' ); return this.processPayment( paymentData ); } async processPayment( paymentData ) { - this.log( 'processPayment', this.context ); + this.log( 'processPayment' ); return new Promise( async ( resolve, reject ) => { try { const id = await this.contextHandler.createOrder(); - this.log( 'processPayment: createOrder', id, this.context ); + this.log( 'processPayment: createOrder', id ); const confirmOrderResponse = await widgetBuilder.paypal .Googlepay() @@ -462,8 +565,7 @@ class GooglepayButton { this.log( 'processPayment: confirmOrder', - confirmOrderResponse, - this.context + confirmOrderResponse ); /** Capture the Order on the Server */ @@ -533,7 +635,7 @@ class GooglepayButton { }; } - this.log( 'processPaymentResponse', response, this.context ); + this.log( 'processPaymentResponse', response ); return response; } diff --git a/modules/ppcp-googlepay/resources/js/GooglepayManager.js b/modules/ppcp-googlepay/resources/js/GooglepayManager.js index e267f1b8a..aaf85a6b0 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayManager.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayManager.js @@ -20,7 +20,7 @@ class GooglepayManager { bootstrap.handler ); - const button = new GooglepayButton( + const button = GooglepayButton.createButton( bootstrap.context, bootstrap.handler, buttonConfig, @@ -30,13 +30,19 @@ class GooglepayManager { this.buttons.push( button ); + const initButton = () => { + button.configure( this.googlePayConfig, this.transactionInfo ); + button.init(); + }; + // Initialize button only if googlePayConfig and transactionInfo are already fetched. if ( this.googlePayConfig && this.transactionInfo ) { - button.init( this.googlePayConfig, this.transactionInfo ); + initButton(); } else { await this.init(); + if ( this.googlePayConfig && this.transactionInfo ) { - button.init( this.googlePayConfig, this.transactionInfo ); + initButton(); } } } ); @@ -53,8 +59,18 @@ class GooglepayManager { this.transactionInfo = await this.fetchTransactionInfo(); } - for ( const button of this.buttons ) { - button.init( this.googlePayConfig, this.transactionInfo ); + 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 ) { console.error( 'Error during initialization:', error ); diff --git a/modules/ppcp-googlepay/resources/js/GooglepayPreviewButton.js b/modules/ppcp-googlepay/resources/js/GooglepayPreviewButton.js new file mode 100644 index 000000000..6c3629a8a --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/GooglepayPreviewButton.js @@ -0,0 +1,78 @@ +import PreviewButton from '../../../ppcp-button/resources/js/modules/Renderer/PreviewButton'; +import ContextHandlerFactory from './Context/ContextHandlerFactory'; +import GooglepayButton from './GooglepayButton'; + +/** + * A single GooglePay preview button instance. + */ +export default class GooglePayPreviewButton extends PreviewButton { + /** + * Instance of the preview button. + * + * @type {?PaymentButton} + */ + #button = null; + + constructor( args ) { + super( args ); + + this.selector = `${ args.selector }GooglePay`; + this.defaultAttributes = { + button: { + style: { + type: 'pay', + color: 'black', + language: 'en', + }, + }, + }; + } + + createNewWrapper() { + const element = super.createNewWrapper(); + element.addClass( 'ppcp-button-googlepay' ); + + return element; + } + + createButton( buttonConfig ) { + const contextHandler = ContextHandlerFactory.create( + 'preview', + buttonConfig, + this.ppcpConfig, + null + ); + + if ( ! this.#button ) { + /* Intentionally using `new` keyword, instead of the `.createButton()` factory, + * as the factory is designed to only create a single button per context, while a single + * page can contain multiple instances of a preview button. + */ + this.#button = new GooglepayButton( + 'preview', + null, + buttonConfig, + this.ppcpConfig, + contextHandler + ); + } + + this.#button.configure( this.apiConfig, null ); + this.#button.applyButtonStyles( buttonConfig, this.ppcpConfig ); + this.#button.reinit(); + } + + /** + * Merge form details into the config object for preview. + * Mutates the previewConfig object; no return value. + * + * @param {Object} buttonConfig + * @param {Object} ppcpConfig + */ + dynamicPreviewConfig( buttonConfig, ppcpConfig ) { + // Merge the current form-values into the preview-button configuration. + if ( ppcpConfig.button && buttonConfig.button ) { + Object.assign( buttonConfig.button.style, ppcpConfig.button.style ); + } + } +} diff --git a/modules/ppcp-googlepay/resources/js/boot-admin.js b/modules/ppcp-googlepay/resources/js/boot-admin.js index b9f7b3c87..7b5342078 100644 --- a/modules/ppcp-googlepay/resources/js/boot-admin.js +++ b/modules/ppcp-googlepay/resources/js/boot-admin.js @@ -1,7 +1,5 @@ -import GooglepayButton from './GooglepayButton'; -import PreviewButton from '../../../ppcp-button/resources/js/modules/Renderer/PreviewButton'; import PreviewButtonManager from '../../../ppcp-button/resources/js/modules/Renderer/PreviewButtonManager'; -import ContextHandlerFactory from './Context/ContextHandlerFactory'; +import GooglePayPreviewButton from './GooglepayPreviewButton'; /** * Accessor that creates and returns a single PreviewButtonManager instance. @@ -33,7 +31,7 @@ class GooglePayPreviewButtonManager extends PreviewButtonManager { * method. * * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. - * @return {Promise<{}>} + * @return {Promise<{}>} Promise that resolves when API configuration is available. */ async fetchConfig( payPal ) { const apiMethod = payPal?.Googlepay()?.config; @@ -59,7 +57,7 @@ class GooglePayPreviewButtonManager extends PreviewButtonManager { * This method is responsible for creating a new PreviewButton instance and returning it. * * @param {string} wrapperId - CSS ID of the wrapper element. - * @return {GooglePayPreviewButton} + * @return {GooglePayPreviewButton} The new preview button instance. */ createButtonInstance( wrapperId ) { return new GooglePayPreviewButton( { @@ -69,64 +67,5 @@ class GooglePayPreviewButtonManager extends PreviewButtonManager { } } -/** - * A single GooglePay preview button instance. - */ -class GooglePayPreviewButton extends PreviewButton { - constructor( args ) { - super( args ); - - this.selector = `${ args.selector }GooglePay`; - this.defaultAttributes = { - button: { - style: { - type: 'pay', - color: 'black', - language: 'en', - }, - }, - }; - } - - createNewWrapper() { - const element = super.createNewWrapper(); - element.addClass( 'ppcp-button-googlepay' ); - - return element; - } - - createButton( buttonConfig ) { - const contextHandler = ContextHandlerFactory.create( - 'preview', - buttonConfig, - this.ppcpConfig, - null - ); - - const button = new GooglepayButton( - 'preview', - null, - buttonConfig, - this.ppcpConfig, - contextHandler - ); - - button.init( this.apiConfig, null ); - } - - /** - * Merge form details into the config object for preview. - * Mutates the previewConfig object; no return value. - * @param buttonConfig - * @param ppcpConfig - */ - dynamicPreviewConfig( buttonConfig, ppcpConfig ) { - // Merge the current form-values into the preview-button configuration. - if ( ppcpConfig.button && buttonConfig.button ) { - Object.assign( buttonConfig.button.style, ppcpConfig.button.style ); - } - } -} - // Initialize the preview button manager. buttonManager(); diff --git a/modules/ppcp-googlepay/src/Assets/Button.php b/modules/ppcp-googlepay/src/Assets/Button.php index bd6faea79..575def21f 100644 --- a/modules/ppcp-googlepay/src/Assets/Button.php +++ b/modules/ppcp-googlepay/src/Assets/Button.php @@ -290,6 +290,7 @@ class Button implements ButtonInterface { $render_placeholder, function () { $this->googlepay_button(); + $this->hide_gateway_until_eligible(); }, 21 ); @@ -303,6 +304,7 @@ class Button implements ButtonInterface { $render_placeholder, function () { $this->googlepay_button(); + $this->hide_gateway_until_eligible(); }, 21 ); @@ -335,6 +337,23 @@ class Button implements ButtonInterface { + + } + * @return {Map} List of input elements found on the current admin page. */ export function getButtonFormFields( apmName ) { const inputFields = document.querySelectorAll( @@ -28,9 +30,9 @@ export function getButtonFormFields( apmName ) { /** * Returns a function that triggers an update of the specified preview button, when invoked. - + * * @param {string} apmName - * @return {((object) => void)} + * @return {((object) => void)} Trigger-function; updates preview buttons when invoked. */ export function buttonRefreshTriggerFactory( apmName ) { const eventName = `ppcp_paypal_render_preview_${ apmName }`; @@ -44,7 +46,7 @@ export function buttonRefreshTriggerFactory( apmName ) { * Returns a function that gets the current form values of the specified preview button. * * @param {string} apmName - * @return {() => {button: {wrapper:string, is_enabled:boolean, style:{}}}} + * @return {() => {button: {wrapper:string, is_enabled:boolean, style:{}}}} Getter-function; returns preview config details when invoked. */ export function buttonSettingsGetterFactory( apmName ) { const fields = getButtonFormFields( apmName );