🔀 Merge branch 'PCP-3477’

This commit is contained in:
Philipp Stracker 2024-08-16 12:49:23 +02:00
commit 01d20b85ee
No known key found for this signature in database
14 changed files with 1587 additions and 339 deletions

View file

@ -7,6 +7,10 @@ import {
PaymentMethods, PaymentMethods,
} from '../Helper/CheckoutMethodState'; } from '../Helper/CheckoutMethodState';
import BootstrapHelper from '../Helper/BootstrapHelper'; import BootstrapHelper from '../Helper/BootstrapHelper';
import {
ButtonEvents,
dispatchButtonEvent,
} from '../Helper/PaymentButtonHelpers';
class CheckoutBootstap { class CheckoutBootstap {
constructor( gateway, renderer, spinner, errorHandler ) { constructor( gateway, renderer, spinner, errorHandler ) {
@ -68,6 +72,7 @@ class CheckoutBootstap {
jQuery( document.body ).on( jQuery( document.body ).on(
'updated_checkout payment_method_selected', 'updated_checkout payment_method_selected',
() => { () => {
this.invalidatePaymentMethods();
this.updateUi(); 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() { updateUi() {
const currentPaymentMethod = getCurrentPaymentMethod(); const currentPaymentMethod = getCurrentPaymentMethod();
const isPaypal = currentPaymentMethod === PaymentMethods.PAYPAL; 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() { shouldShowMessages() {

View file

@ -26,8 +26,11 @@ export function setupButtonEvents( refresh ) {
document.addEventListener( REFRESH_BUTTON_EVENT, debouncedRefresh ); document.addEventListener( REFRESH_BUTTON_EVENT, debouncedRefresh );
// Listen for cart and checkout update events. // Listen for cart and checkout update events.
document.body.addEventListener( 'updated_cart_totals', debouncedRefresh ); // Note: we need jQuery here, because WooCommerce uses jQuery.trigger() to dispatch the events.
document.body.addEventListener( 'updated_checkout', debouncedRefresh ); window
.jQuery( 'body' )
.on( 'updated_cart_totals', debouncedRefresh )
.on( 'updated_checkout', debouncedRefresh );
// Use setTimeout for fragment events to avoid unnecessary refresh on initial render. // Use setTimeout for fragment events to avoid unnecessary refresh on initial render.
setTimeout( () => { setTimeout( () => {

View file

@ -6,6 +6,30 @@ export const PaymentMethods = {
GOOGLEPAY: 'ppcp-googlepay', 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 ORDER_BUTTON_SELECTOR = '#place_order';
export const getCurrentPaymentMethod = () => { export const getCurrentPaymentMethod = () => {

View file

@ -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 );
}

View file

@ -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<any, any>} 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;
}
}

View file

@ -1,11 +1,17 @@
import { loadCustomScript } from '@paypal/paypal-js'; import { loadCustomScript } from '@paypal/paypal-js';
import widgetBuilder from './WidgetBuilder'; import widgetBuilder from './WidgetBuilder';
import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce'; 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. * Manages all PreviewButton instances of a certain payment method on the page.
*/ */
class PreviewButtonManager { class PreviewButtonManager {
/**
* @type {ConsoleLogger}
*/
#logger;
/** /**
* Resolves the promise. * Resolves the promise.
* Used by `this.boostrap()` to process enqueued initialization logic. * Used by `this.boostrap()` to process enqueued initialization logic.
@ -32,6 +38,9 @@ class PreviewButtonManager {
this.apiConfig = null; this.apiConfig = null;
this.apiError = ''; 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.#onInit = new Promise( ( resolve ) => {
this.#onInitResolver = resolve; this.#onInitResolver = resolve;
} ); } );
@ -61,9 +70,11 @@ class PreviewButtonManager {
* Responsible for fetching and returning the PayPal configuration object for this payment * Responsible for fetching and returning the PayPal configuration object for this payment
* method. * method.
* *
* @abstract
* @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder.
* @return {Promise<{}>} * @return {Promise<{}>}
*/ */
// eslint-disable-next-line no-unused-vars
async fetchConfig( payPal ) { async fetchConfig( payPal ) {
throw new Error( throw new Error(
'The "fetchConfig" method must be implemented by the derived class' '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. * Protected method that needs to be implemented by the derived class.
* This method is responsible for creating a new PreviewButton instance and returning it. * This method is responsible for creating a new PreviewButton instance and returning it.
* *
* @abstract
* @param {string} wrapperId - CSS ID of the wrapper element. * @param {string} wrapperId - CSS ID of the wrapper element.
* @return {PreviewButton} * @return {PreviewButton}
*/ */
// eslint-disable-next-line no-unused-vars
createButtonInstance( wrapperId ) { createButtonInstance( wrapperId ) {
throw new Error( throw new Error(
'The "createButtonInstance" method must be implemented by the derived class' '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. * 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. * @todo Consider refactoring this into a new class that extends the PreviewButton class.
* @param wrapperId * @param {string} wrapperId
* @return {any} * @return {any}
*/ */
createDummy( wrapperId ) { 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. * 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 ) { error( message, ...args ) {
console.error( `${ this.methodName } ${ message }`, ...args ); this.#logger.error( message, ...args );
} }
/** /**
@ -242,21 +266,21 @@ class PreviewButtonManager {
} }
if ( ! this.shouldInsertPreviewButton( id ) ) { if ( ! this.shouldInsertPreviewButton( id ) ) {
this.log( 'Skip preview rendering for this preview-box', id );
return; return;
} }
if ( ! this.buttons[ id ] ) { if ( ! this.buttons[ id ] ) {
this._addButton( id, ppcpConfig ); this._addButton( id, ppcpConfig );
} else { } else {
// This is a debounced method, that fires after 100ms. this._configureButton( id, ppcpConfig );
this._configureAllButtons( ppcpConfig );
} }
} }
/** /**
* Determines if the preview box supports the current button. * 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. * @param {string} previewId - ID of the inner preview box container.
* @return {boolean} True if the box is eligible for the preview button, false otherwise. * @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. * Applies a new configuration to an existing preview button.
*
* @private
* @param id * @param id
* @param ppcpConfig * @param ppcpConfig
*/ */
_configureButton( id, ppcpConfig ) { _configureButton( id, ppcpConfig ) {
this.log( 'configureButton', id, ppcpConfig );
this.buttons[ id ] this.buttons[ id ]
.setDynamic( this.isDynamic() ) .setDynamic( this.isDynamic() )
.setPpcpConfig( ppcpConfig ) .setPpcpConfig( ppcpConfig )
@ -283,9 +311,13 @@ class PreviewButtonManager {
/** /**
* Apples the provided configuration to all existing preview buttons. * 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 ) { _configureAllButtons( ppcpConfig ) {
this.log( 'configureAllButtons', ppcpConfig );
Object.entries( this.buttons ).forEach( ( [ id, button ] ) => { Object.entries( this.buttons ).forEach( ( [ id, button ] ) => {
this._configureButton( id, { this._configureButton( id, {
...ppcpConfig, ...ppcpConfig,
@ -302,13 +334,20 @@ class PreviewButtonManager {
/** /**
* Creates a new preview button, that is rendered once the bootstrapping Promise resolves. * 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 ) { _addButton( id, ppcpConfig ) {
this.log( 'addButton', id, ppcpConfig );
const createButton = () => { const createButton = () => {
if ( ! this.buttons[ id ] ) { if ( ! this.buttons[ id ] ) {
this.log( 'createButton.new', id );
let newInst; let newInst;
if ( this.apiConfig && 'object' === typeof this.apiConfig ) { if ( this.apiConfig && 'object' === typeof this.apiConfig ) {
newInst = this.createButtonInstance( id ).setButtonConfig( newInst = this.createButtonInstance( id ).setButtonConfig(
this.buttonConfig this.buttonConfig

View file

@ -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 { .ppcp-button-googlepay {
min-height: 40px; min-height: 40px;

View file

@ -1,10 +1,120 @@
import { setVisible } from '../../../ppcp-button/resources/js/modules/Helper/Hiding'; import {
import { setEnabled } from '../../../ppcp-button/resources/js/modules/Helper/ButtonDisabler'; 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 widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder';
import UpdatePaymentData from './Helper/UpdatePaymentData'; 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( constructor(
context, context,
externalHandler, externalHandler,
@ -12,274 +122,257 @@ class GooglepayButton {
ppcpConfig, ppcpConfig,
contextHandler 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.init = this.init.bind( this );
this.externalHandler = externalHandler; this.onPaymentAuthorized = this.onPaymentAuthorized.bind( this );
this.buttonConfig = buttonConfig; this.onPaymentDataChanged = this.onPaymentDataChanged.bind( this );
this.ppcpConfig = ppcpConfig; this.onButtonClick = this.onButtonClick.bind( this );
this.contextHandler = contextHandler;
this.paymentsClient = null; this.log( 'Create instance' );
}
this.log = function () { /**
if ( this.buttonConfig.is_debug ) { * @inheritDoc
//console.log('[GooglePayButton]', ...arguments); */
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 ( ! validEnvs.includes( this.buttonConfig.environment ) ) {
if ( this.isInitialized ) { return isInvalid(
return; 'Invalid environment:',
} this.buttonConfig.environment
this.isInitialized = true; );
if ( ! this.validateConfig() ) {
return;
} }
if ( ! this.contextHandler.validateContext() ) { // Preview buttons only need a valid environment.
return; 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 ) { if ( ! this.googlePayConfig ) {
return; return isInvalid(
'No API configuration - missing configure() call?'
);
} }
this.isInitialized = false; if ( ! this.transactionInfo ) {
this.init( this.googlePayConfig, this.transactionInfo ); return isInvalid(
} 'No transactionInfo - missing configure() call?'
validateConfig() {
if (
[ 'PRODUCTION', 'TEST' ].indexOf(
this.buttonConfig.environment
) === -1
) {
console.error(
'[GooglePayButton] Invalid environment.',
this.buttonConfig.environment
); );
return false;
} }
if ( ! this.contextHandler ) { if ( ! typeof this.contextHandler?.validateContext() ) {
console.error( return isInvalid( 'Invalid context handler.', this.contextHandler );
'[GooglePayButton] Invalid context handler.',
this.contextHandler
);
return false;
} }
return true; 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() { configure( apiConfig, transactionInfo ) {
const config = { this.googlePayConfig = apiConfig;
wrapper: this.buttonConfig.button.wrapper, this.#transactionInfo = transactionInfo;
ppcpStyle: this.ppcpConfig.button.style,
buttonStyle: this.buttonConfig.button.style,
ppcpButtonWrapper: this.ppcpConfig.button.wrapper,
};
if ( this.context === 'mini-cart' ) { this.allowedPaymentMethods = this.googlePayConfig.allowedPaymentMethods;
config.wrapper = this.buttonConfig.button.mini_cart_wrapper; this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ];
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;
} }
initClient() { init() {
const callbacks = { // Use `reinit()` to force a full refresh of an initialized button.
onPaymentAuthorized: this.onPaymentAuthorized.bind( this ), if ( this.isInitialized ) {
}; return;
if (
this.buttonConfig.shipping.enabled &&
this.contextHandler.shippingAllowed()
) {
callbacks.onPaymentDataChanged =
this.onPaymentDataChanged.bind( this );
} }
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, environment: this.buttonConfig.environment,
// add merchant info maybe
paymentDataCallbacks: callbacks, 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 ) { buildReadyToPayRequest( allowedPaymentMethods, baseRequest ) {
this.log( 'Ready To Pay request', baseRequest, allowedPaymentMethods );
return Object.assign( {}, baseRequest, { return Object.assign( {}, baseRequest, {
allowedPaymentMethods, allowedPaymentMethods,
} ); } );
} }
/** /**
* Add a Google Pay purchase button * Creates the payment button and calls `this.insertButton()` to make the button visible in the
* @param baseCardPaymentMethod * correct wrapper.
*/ */
addButton( baseCardPaymentMethod ) { addButton() {
this.log( 'addButton', this.context ); 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 ); * @see https://developers.google.com/pay/api/web/reference/client#createButton
*/
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 ) {
const button = this.paymentsClient.createButton( { const button = this.paymentsClient.createButton( {
onClick: this.onButtonClick.bind( this ), onClick: this.onButtonClick,
allowedPaymentMethods: [ baseCardPaymentMethod ], allowedPaymentMethods: [ baseCardPaymentMethod ],
buttonColor: buttonStyle.color || 'black', buttonColor: color || 'black',
buttonType: buttonStyle.type || 'pay', buttonType: type || 'pay',
buttonLocale: buttonStyle.language || 'en', buttonLocale: language || 'en',
buttonSizeMode: 'fill', buttonSizeMode: 'fill',
} ); } );
wrapper.appendChild( button ); this.insertButton( 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 );
} }
//------------------------ //------------------------
@ -290,35 +383,49 @@ class GooglepayButton {
* Show Google Pay payment sheet when Google Pay payment button is clicked * Show Google Pay payment sheet when Google Pay payment button is clicked
*/ */
onButtonClick() { onButtonClick() {
this.log( 'onButtonClick', this.context ); this.log( 'onButtonClick' );
const initiatePaymentRequest = () => { const initiatePaymentRequest = () => {
window.ppcpFundingSource = 'googlepay'; window.ppcpFundingSource = 'googlepay';
const paymentDataRequest = this.paymentDataRequest(); const paymentDataRequest = this.paymentDataRequest();
this.log( this.log(
'onButtonClick: paymentDataRequest', 'onButtonClick: paymentDataRequest',
paymentDataRequest, paymentDataRequest,
this.context this.context
); );
this.paymentsClient.loadPaymentData( paymentDataRequest ); this.paymentsClient.loadPaymentData( paymentDataRequest );
}; };
if ( 'function' === typeof this.contextHandler.validateForm ) { const validateForm = () => {
// During regular checkout, validate the checkout form before initiating the payment. if ( 'function' !== typeof this.contextHandler.validateForm ) {
this.contextHandler return Promise.resolve();
.validateForm() }
.then( initiatePaymentRequest, () => {
console.error( return this.contextHandler.validateForm().catch( ( error ) => {
'[GooglePayButton] Form validation failed.' 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() { paymentDataRequest() {
@ -334,10 +441,7 @@ class GooglepayButton {
paymentDataRequest.transactionInfo = this.transactionInfo; paymentDataRequest.transactionInfo = this.transactionInfo;
paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo; paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo;
if ( if ( this.requiresShipping ) {
this.buttonConfig.shipping.enabled &&
this.contextHandler.shippingAllowed()
) {
paymentDataRequest.callbackIntents = [ paymentDataRequest.callbackIntents = [
'SHIPPING_ADDRESS', 'SHIPPING_ADDRESS',
'SHIPPING_OPTION', 'SHIPPING_OPTION',
@ -366,8 +470,7 @@ class GooglepayButton {
} }
onPaymentDataChanged( paymentData ) { onPaymentDataChanged( paymentData ) {
this.log( 'onPaymentDataChanged', this.context ); this.log( 'onPaymentDataChanged', paymentData );
this.log( 'paymentData', paymentData );
return new Promise( async ( resolve, reject ) => { return new Promise( async ( resolve, reject ) => {
try { try {
@ -412,7 +515,7 @@ class GooglepayButton {
resolve( paymentDataRequestUpdate ); resolve( paymentDataRequestUpdate );
} catch ( error ) { } catch ( error ) {
console.error( 'Error during onPaymentDataChanged:', error ); this.error( 'Error during onPaymentDataChanged:', error );
reject( error ); reject( error );
} }
} ); } );
@ -440,18 +543,18 @@ class GooglepayButton {
//------------------------ //------------------------
onPaymentAuthorized( paymentData ) { onPaymentAuthorized( paymentData ) {
this.log( 'onPaymentAuthorized', this.context ); this.log( 'onPaymentAuthorized' );
return this.processPayment( paymentData ); return this.processPayment( paymentData );
} }
async processPayment( paymentData ) { async processPayment( paymentData ) {
this.log( 'processPayment', this.context ); this.log( 'processPayment' );
return new Promise( async ( resolve, reject ) => { return new Promise( async ( resolve, reject ) => {
try { try {
const id = await this.contextHandler.createOrder(); const id = await this.contextHandler.createOrder();
this.log( 'processPayment: createOrder', id, this.context ); this.log( 'processPayment: createOrder', id );
const confirmOrderResponse = await widgetBuilder.paypal const confirmOrderResponse = await widgetBuilder.paypal
.Googlepay() .Googlepay()
@ -462,8 +565,7 @@ class GooglepayButton {
this.log( this.log(
'processPayment: confirmOrder', 'processPayment: confirmOrder',
confirmOrderResponse, confirmOrderResponse
this.context
); );
/** Capture the Order on the Server */ /** Capture the Order on the Server */
@ -533,7 +635,7 @@ class GooglepayButton {
}; };
} }
this.log( 'processPaymentResponse', response, this.context ); this.log( 'processPaymentResponse', response );
return response; return response;
} }

View file

@ -20,7 +20,7 @@ class GooglepayManager {
bootstrap.handler bootstrap.handler
); );
const button = new GooglepayButton( const button = GooglepayButton.createButton(
bootstrap.context, bootstrap.context,
bootstrap.handler, bootstrap.handler,
buttonConfig, buttonConfig,
@ -30,13 +30,19 @@ class GooglepayManager {
this.buttons.push( button ); this.buttons.push( button );
const initButton = () => {
button.configure( this.googlePayConfig, this.transactionInfo );
button.init();
};
// Initialize button only if googlePayConfig and transactionInfo are already fetched. // Initialize button only if googlePayConfig and transactionInfo are already fetched.
if ( this.googlePayConfig && this.transactionInfo ) { if ( this.googlePayConfig && this.transactionInfo ) {
button.init( this.googlePayConfig, this.transactionInfo ); initButton();
} else { } else {
await this.init(); await this.init();
if ( this.googlePayConfig && this.transactionInfo ) { if ( this.googlePayConfig && this.transactionInfo ) {
button.init( this.googlePayConfig, this.transactionInfo ); initButton();
} }
} }
} ); } );
@ -53,8 +59,18 @@ class GooglepayManager {
this.transactionInfo = await this.fetchTransactionInfo(); this.transactionInfo = await this.fetchTransactionInfo();
} }
for ( const button of this.buttons ) { if ( ! this.googlePayConfig ) {
button.init( this.googlePayConfig, this.transactionInfo ); 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 ) { } catch ( error ) {
console.error( 'Error during initialization:', error ); console.error( 'Error during initialization:', error );

View file

@ -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 );
}
}
}

View file

@ -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 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. * Accessor that creates and returns a single PreviewButtonManager instance.
@ -33,7 +31,7 @@ class GooglePayPreviewButtonManager extends PreviewButtonManager {
* method. * method.
* *
* @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder.
* @return {Promise<{}>} * @return {Promise<{}>} Promise that resolves when API configuration is available.
*/ */
async fetchConfig( payPal ) { async fetchConfig( payPal ) {
const apiMethod = payPal?.Googlepay()?.config; 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. * This method is responsible for creating a new PreviewButton instance and returning it.
* *
* @param {string} wrapperId - CSS ID of the wrapper element. * @param {string} wrapperId - CSS ID of the wrapper element.
* @return {GooglePayPreviewButton} * @return {GooglePayPreviewButton} The new preview button instance.
*/ */
createButtonInstance( wrapperId ) { createButtonInstance( wrapperId ) {
return new GooglePayPreviewButton( { 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. // Initialize the preview button manager.
buttonManager(); buttonManager();

View file

@ -290,6 +290,7 @@ class Button implements ButtonInterface {
$render_placeholder, $render_placeholder,
function () { function () {
$this->googlepay_button(); $this->googlepay_button();
$this->hide_gateway_until_eligible();
}, },
21 21
); );
@ -303,6 +304,7 @@ class Button implements ButtonInterface {
$render_placeholder, $render_placeholder,
function () { function () {
$this->googlepay_button(); $this->googlepay_button();
$this->hide_gateway_until_eligible();
}, },
21 21
); );
@ -335,6 +337,23 @@ class Button implements ButtonInterface {
<?php <?php
} }
/**
* Outputs an inline CSS style that hides the Google Pay gateway (on Classic Checkout).
* The style is removed by `PaymentButton.js` once the eligibility of the payment method
* is confirmed.
*
* @return void
*/
protected function hide_gateway_until_eligible() : void {
?>
<style data-hide-gateway='<?php echo esc_attr( GooglePayGateway::ID ); ?>'>
.wc_payment_method.payment_method_ppcp-googlepay {
display: none;
}
</style>
<?php
}
/** /**
* Enqueues scripts/styles. * Enqueues scripts/styles.
*/ */

View file

@ -0,0 +1,58 @@
/**
* Helper component to log debug details to the browser console.
*
* A utility class that is used by payment buttons on the front-end, like the GooglePayButton.
*/
export default class ConsoleLogger {
/**
* The prefix to display before every log output.
*
* @type {string}
*/
#prefix = '';
/**
* Whether logging is enabled, disabled by default.
*
* @type {boolean}
*/
#enabled = false;
constructor( ...prefixes ) {
if ( prefixes.length ) {
this.#prefix = `[${ prefixes.join( ' | ' ) }]`;
}
}
/**
* Enable or disable logging. Only impacts `log()` output.
*
* @param {boolean} state True to enable log output.
*/
set enabled( state ) {
this.#enabled = state;
}
/**
* Output log-level details to the browser console, if logging is enabled.
*
* @param {...any} args - All provided values are output to the browser console.
*/
log( ...args ) {
if ( this.#enabled ) {
// eslint-disable-next-line
console.log( this.#prefix, ...args );
}
}
/**
* Generate an error message in the browser's console.
*
* Error messages are always output, even when logging is disabled.
*
* @param {...any} args - All provided values are output to the browser console.
*/
error( ...args ) {
console.error( this.#prefix, ...args );
}
}

View file

@ -1,9 +1,11 @@
/* global jQuery */
/** /**
* Returns a Map with all input fields that are relevant to render the preview of the * Returns a Map with all input fields that are relevant to render the preview of the
* given payment button. * given payment button.
* *
* @param {string} apmName - Value of the custom attribute `data-ppcp-apm-name`. * @param {string} apmName - Value of the custom attribute `data-ppcp-apm-name`.
* @return {Map<string, {val:Function, el:HTMLInputElement}>} * @return {Map<string, {val:Function, el:HTMLInputElement}>} List of input elements found on the current admin page.
*/ */
export function getButtonFormFields( apmName ) { export function getButtonFormFields( apmName ) {
const inputFields = document.querySelectorAll( 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. * Returns a function that triggers an update of the specified preview button, when invoked.
*
* @param {string} apmName * @param {string} apmName
* @return {((object) => void)} * @return {((object) => void)} Trigger-function; updates preview buttons when invoked.
*/ */
export function buttonRefreshTriggerFactory( apmName ) { export function buttonRefreshTriggerFactory( apmName ) {
const eventName = `ppcp_paypal_render_preview_${ 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. * Returns a function that gets the current form values of the specified preview button.
* *
* @param {string} apmName * @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 ) { export function buttonSettingsGetterFactory( apmName ) {
const fields = getButtonFormFields( apmName ); const fields = getButtonFormFields( apmName );