🔀 Merge branch 'trunk'

# Conflicts:
#	modules/ppcp-applepay/resources/js/ApplepayButton.js
#	modules/ppcp-applepay/resources/js/boot-admin.js
#	modules/ppcp-button/resources/js/modules/Preview/PreviewButtonManager.js
#	modules/ppcp-googlepay/resources/js/GooglepayButton.js
#	modules/ppcp-googlepay/resources/js/boot-admin.js
This commit is contained in:
Philipp Stracker 2024-09-06 15:19:17 +02:00
commit a128228c86
No known key found for this signature in database
303 changed files with 21512 additions and 5378 deletions

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 {
min-height: 40px;
@ -18,3 +25,7 @@
min-width: 0 !important;
}
}
#ppc-button-ppcp-googlepay {
display: none;
}

View file

@ -4,7 +4,7 @@ import CheckoutActionHandler from '../../../../ppcp-button/resources/js/modules/
import FormValidator from '../../../../ppcp-button/resources/js/modules/Helper/FormValidator';
class CheckoutHandler extends BaseHandler {
transactionInfo() {
validateForm() {
return new Promise( async ( resolve, reject ) => {
try {
const spinner = new Spinner();
@ -23,7 +23,7 @@ class CheckoutHandler extends BaseHandler {
: null;
if ( ! formValidator ) {
resolve( super.transactionInfo() );
resolve();
return;
}
@ -42,7 +42,7 @@ class CheckoutHandler extends BaseHandler {
reject();
} else {
resolve( super.transactionInfo() );
resolve();
}
} );
} catch ( error ) {

View file

@ -0,0 +1,102 @@
import { GooglePayStorage } from '../Helper/GooglePayStorage';
import {
getWooCommerceCustomerDetails,
setPayerData,
} from '../../../../ppcp-button/resources/js/modules/Helper/PayerData';
const CHECKOUT_FORM_SELECTOR = 'form.woocommerce-checkout';
export class CheckoutBootstrap {
/**
* @type {GooglePayStorage}
*/
#storage;
/**
* @type {HTMLFormElement|null}
*/
#checkoutForm;
/**
* @param {GooglePayStorage} storage
*/
constructor( storage ) {
this.#storage = storage;
this.#checkoutForm = CheckoutBootstrap.getCheckoutForm();
}
/**
* Indicates if the current page contains a checkout form.
*
* @return {boolean} True if a checkout form is present.
*/
static isPageWithCheckoutForm() {
return null !== CheckoutBootstrap.getCheckoutForm();
}
/**
* Retrieves the WooCommerce checkout form element.
*
* @return {HTMLFormElement|null} The form, or null if not a checkout page.
*/
static getCheckoutForm() {
return document.querySelector( CHECKOUT_FORM_SELECTOR );
}
/**
* Returns the WooCommerce checkout form element.
*
* @return {HTMLFormElement|null} The form, or null if not a checkout page.
*/
get checkoutForm() {
return this.#checkoutForm;
}
/**
* Initializes the checkout process.
*
* @throws {Error} If called on a page without a checkout form.
*/
init() {
if ( ! this.#checkoutForm ) {
throw new Error(
'Checkout form not found. Cannot initialize CheckoutBootstrap.'
);
}
this.#populateCheckoutFields();
}
/**
* Populates checkout fields with stored or customer data.
*/
#populateCheckoutFields() {
const loggedInData = getWooCommerceCustomerDetails();
if ( loggedInData ) {
// If customer is logged in, we use the details from the customer profile.
return;
}
const billingData = this.#storage.getPayer();
if ( ! billingData ) {
return;
}
setPayerData( billingData, true );
this.checkoutForm.addEventListener(
'submit',
this.#onFormSubmit.bind( this )
);
}
/**
* Clean-up when checkout form is submitted.
*
* Immediately removes the payer details from the localStorage.
*/
#onFormSubmit() {
this.#storage.clearPayer();
}
}

View file

@ -1,242 +1,412 @@
import ContextHandlerFactory from './Context/ContextHandlerFactory';
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';
import { setPayerData } from '../../../ppcp-button/resources/js/modules/Helper/PayerData';
import moduleStorage from './Helper/GooglePayStorage';
class GooglepayButton {
constructor( context, externalHandler, buttonConfig, ppcpConfig ) {
apmButtonsInit( ppcpConfig );
/**
* 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.
*/
this.isInitialized = false;
/**
* 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.
*/
this.context = context;
this.externalHandler = externalHandler;
this.buttonConfig = buttonConfig;
this.ppcpConfig = ppcpConfig;
/**
* 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.
*/
this.paymentsClient = null;
/**
* 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 {(Object) => Promise} loadPaymentData - This method presents a Google Pay payment
* sheet that allows selection of a payment method and optionally configured parameters
* @property {Function} onPaymentAuthorized - This method is called when a payment is
* authorized in the payment sheet.
* @property {Function} onPaymentDataChanged - This method handles payment data changes
* in the payment sheet such as shipping address and shipping options.
*/
this.contextHandler = ContextHandlerFactory.create(
this.context,
this.buttonConfig,
this.ppcpConfig,
this.externalHandler
/**
* 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.
*/
function payerDataFromPaymentResponse( response ) {
const raw = response?.paymentMethodData?.info?.billingAddress;
return {
email_address: response?.email,
name: {
given_name: raw.name.split( ' ' )[ 0 ], // Assuming first name is the first part
surname: raw.name.split( ' ' ).slice( 1 ).join( ' ' ), // Assuming last name is the rest
},
address: {
country_code: raw.countryCode,
address_line_1: raw.address1,
address_line_2: raw.address2,
admin_area_1: raw.administrativeArea,
admin_area_2: raw.locality,
postal_code: raw.postalCode,
},
};
}
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, provided to the Google SDK.
*
* @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 || {}
);
this.log = function () {
if ( this.buttonConfig.is_debug ) {
//console.log('[GooglePayButton]', ...arguments);
if ( 'buy' === styles.MiniCart.type ) {
styles.MiniCart.type = 'pay';
}
return styles;
}
constructor(
context,
externalHandler,
buttonConfig,
ppcpConfig,
contextHandler
) {
// Disable debug output in the browser console:
// buttonConfig.is_debug = false;
super(
context,
externalHandler,
buttonConfig,
ppcpConfig,
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.log( 'Create instance' );
}
/**
* @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 ) {
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.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 ) {
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 );
}
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, () => {
const $wrapper = jQuery( wrapper );
$wrapper.removeClass( 'ppcp-button-rect ppcp-button-pill' );
$wrapper.addClass( 'ppcp-button-' + ppcpStyle.shape );
if ( ppcpStyle.height ) {
$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',
} );
$wrapper.append( button );
/**
* @see https://developers.google.com/pay/api/web/reference/client#createButton
*/
const button = this.paymentsClient.createButton( {
onClick: this.onButtonClick,
allowedPaymentMethods: [ baseCardPaymentMethod ],
buttonColor: color || 'black',
buttonType: type || 'pay',
buttonLocale: language || 'en',
buttonSizeMode: 'fill',
} );
}
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 );
}
//------------------------
@ -246,53 +416,78 @@ class GooglepayButton {
/**
* Show Google Pay payment sheet when Google Pay payment button is clicked
*/
async onButtonClick() {
this.log( 'onButtonClick', this.context );
onButtonClick() {
this.log( 'onButtonClick' );
const paymentDataRequest = await this.paymentDataRequest();
this.log(
'onButtonClick: paymentDataRequest',
paymentDataRequest,
this.context
);
const initiatePaymentRequest = () => {
window.ppcpFundingSource = 'googlepay';
const paymentDataRequest = this.paymentDataRequest();
window.ppcpFundingSource = 'googlepay'; // Do this on another place like on create order endpoint handler.
this.log(
'onButtonClick: paymentDataRequest',
paymentDataRequest,
this.context
);
this.paymentsClient.loadPaymentData( paymentDataRequest );
return this.paymentsClient.loadPaymentData( paymentDataRequest );
};
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;
} );
};
validateForm()
.then( getTransactionInfo )
.then( initiatePaymentRequest );
}
async paymentDataRequest() {
paymentDataRequest() {
const baseRequest = {
apiVersion: 2,
apiVersionMinor: 0,
};
const googlePayConfig = this.googlePayConfig;
const paymentDataRequest = Object.assign( {}, baseRequest );
paymentDataRequest.allowedPaymentMethods =
googlePayConfig.allowedPaymentMethods;
paymentDataRequest.transactionInfo =
await this.contextHandler.transactionInfo();
paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo;
const useShippingCallback = this.requiresShipping;
const callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
if (
this.buttonConfig.shipping.enabled &&
this.contextHandler.shippingAllowed()
) {
paymentDataRequest.callbackIntents = [
'SHIPPING_ADDRESS',
'SHIPPING_OPTION',
'PAYMENT_AUTHORIZATION',
];
paymentDataRequest.shippingAddressRequired = true;
paymentDataRequest.shippingAddressParameters =
this.shippingAddressParameters();
paymentDataRequest.shippingOptionRequired = true;
} else {
paymentDataRequest.callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
if ( useShippingCallback ) {
callbackIntents.push( 'SHIPPING_ADDRESS', 'SHIPPING_OPTION' );
}
return paymentDataRequest;
return {
...baseRequest,
allowedPaymentMethods: this.googlePayConfig.allowedPaymentMethods,
transactionInfo: this.transactionInfo,
merchantInfo: this.googlePayConfig.merchantInfo,
callbackIntents,
emailRequired: true,
shippingAddressRequired: useShippingCallback,
shippingOptionRequired: useShippingCallback,
shippingAddressParameters: this.shippingAddressParameters(),
};
}
//------------------------
@ -307,47 +502,54 @@ class GooglepayButton {
}
onPaymentDataChanged( paymentData ) {
this.log( 'onPaymentDataChanged', this.context );
this.log( 'paymentData', paymentData );
this.log( 'onPaymentDataChanged', paymentData );
return new Promise( async ( resolve, reject ) => {
const paymentDataRequestUpdate = {};
try {
const paymentDataRequestUpdate = {};
const updatedData = await new UpdatePaymentData(
this.buttonConfig.ajax.update_payment_data
).update( paymentData );
const transactionInfo = await this.contextHandler.transactionInfo();
const updatedData = await new UpdatePaymentData(
this.buttonConfig.ajax.update_payment_data
).update( paymentData );
const transactionInfo = this.transactionInfo;
this.log( 'onPaymentDataChanged:updatedData', updatedData );
this.log( 'onPaymentDataChanged:transactionInfo', transactionInfo );
this.log( 'onPaymentDataChanged:updatedData', updatedData );
this.log(
'onPaymentDataChanged:transactionInfo',
transactionInfo
);
updatedData.country_code = transactionInfo.countryCode;
updatedData.currency_code = transactionInfo.currencyCode;
updatedData.total_str = transactionInfo.totalPrice;
updatedData.country_code = transactionInfo.countryCode;
updatedData.currency_code = transactionInfo.currencyCode;
updatedData.total_str = transactionInfo.totalPrice;
// Handle unserviceable address.
if ( ! updatedData.shipping_options?.shippingOptions?.length ) {
paymentDataRequestUpdate.error =
this.unserviceableShippingAddressError();
resolve( paymentDataRequestUpdate );
return;
}
switch ( paymentData.callbackTrigger ) {
case 'INITIALIZE':
case 'SHIPPING_ADDRESS':
paymentDataRequestUpdate.newShippingOptionParameters =
updatedData.shipping_options;
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( updatedData );
break;
case 'SHIPPING_OPTION':
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( updatedData );
break;
}
// Handle unserviceable address.
if ( ! updatedData.shipping_options?.shippingOptions?.length ) {
paymentDataRequestUpdate.error =
this.unserviceableShippingAddressError();
resolve( paymentDataRequestUpdate );
return;
} catch ( error ) {
this.error( 'Error during onPaymentDataChanged:', error );
reject( error );
}
switch ( paymentData.callbackTrigger ) {
case 'INITIALIZE':
case 'SHIPPING_ADDRESS':
paymentDataRequestUpdate.newShippingOptionParameters =
updatedData.shipping_options;
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( updatedData );
break;
case 'SHIPPING_OPTION':
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( updatedData );
break;
}
resolve( paymentDataRequestUpdate );
} );
}
@ -373,84 +575,111 @@ class GooglepayButton {
//------------------------
onPaymentAuthorized( paymentData ) {
this.log( 'onPaymentAuthorized', this.context );
this.log( 'onPaymentAuthorized', paymentData );
return this.processPayment( paymentData );
}
async processPayment( paymentData ) {
this.log( 'processPayment', this.context );
this.logGroup( 'processPayment' );
return new Promise( async ( resolve, reject ) => {
try {
const id = await this.contextHandler.createOrder();
const payer = payerDataFromPaymentResponse( paymentData );
this.log( 'processPayment: createOrder', id, this.context );
const paymentError = ( reason ) => {
this.error( reason );
const confirmOrderResponse = await widgetBuilder.paypal
.Googlepay()
.confirmOrder( {
orderId: id,
paymentMethodData: paymentData.paymentMethodData,
} );
return this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
reason
);
};
this.log(
'processPayment: confirmOrder',
confirmOrderResponse,
this.context
);
const checkPayPalApproval = async ( orderId ) => {
const confirmationData = {
orderId,
paymentMethodData: paymentData.paymentMethodData,
};
/** Capture the Order on the Server */
if ( confirmOrderResponse.status === 'APPROVED' ) {
let approveFailed = false;
await this.contextHandler.approveOrder(
{
orderID: id,
},
{
// actions mock object.
restart: () =>
new Promise( ( resolve, reject ) => {
approveFailed = true;
resolve();
} ),
order: {
get: () =>
new Promise( ( resolve, reject ) => {
resolve( null );
} ),
},
}
);
const confirmOrderResponse = await widgetBuilder.paypal
.Googlepay()
.confirmOrder( confirmationData );
if ( ! approveFailed ) {
resolve( this.processPaymentResponse( 'SUCCESS' ) );
} else {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
'FAILED TO APPROVE'
)
);
}
} else {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
'TRANSACTION FAILED'
)
);
this.log( 'confirmOrder', confirmOrderResponse );
return 'APPROVED' === confirmOrderResponse?.status;
};
/**
* This approval mainly confirms that the orderID is valid.
*
* It's still needed because this handler redirects to the checkout page if the server-side
* approval was successful.
*
* @param {string} orderID
*/
const approveOrderServerSide = async ( orderID ) => {
let isApproved = true;
this.log( 'approveOrder', orderID );
await this.contextHandler.approveOrder(
{ orderID, payer },
{
restart: () =>
new Promise( ( resolve ) => {
isApproved = false;
resolve();
} ),
order: {
get: () =>
new Promise( ( resolve ) => {
resolve( null );
} ),
},
}
} catch ( err ) {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
err.message
)
);
);
return isApproved;
};
const processPaymentPromise = async ( resolve ) => {
const id = await this.contextHandler.createOrder();
this.log( 'createOrder', id );
const isApprovedByPayPal = await checkPayPalApproval( id );
if ( ! isApprovedByPayPal ) {
resolve( paymentError( 'TRANSACTION FAILED' ) );
return;
}
// This must be the last step in the process, as it initiates a redirect.
const success = await approveOrderServerSide( id );
if ( success ) {
resolve( this.processPaymentResponse( 'SUCCESS' ) );
} else {
resolve( paymentError( 'FAILED TO APPROVE' ) );
}
};
const addBillingDataToSession = () => {
moduleStorage.setPayer( payer );
setPayerData( payer );
};
return new Promise( async ( resolve ) => {
try {
addBillingDataToSession();
await processPaymentPromise( resolve );
} catch ( err ) {
resolve( paymentError( err.message ) );
}
this.logGroup();
} );
}
@ -466,7 +695,7 @@ class GooglepayButton {
};
}
this.log( 'processPaymentResponse', response, this.context );
this.log( 'processPaymentResponse', response );
return response;
}

View file

@ -1,39 +1,92 @@
import buttonModuleWatcher from '../../../ppcp-button/resources/js/modules/ButtonModuleWatcher';
import GooglepayButton from './GooglepayButton';
import ContextHandlerFactory from './Context/ContextHandlerFactory';
class GooglepayManager {
constructor( buttonConfig, ppcpConfig ) {
this.buttonConfig = buttonConfig;
this.ppcpConfig = ppcpConfig;
this.googlePayConfig = null;
this.transactionInfo = null;
this.contextHandler = null;
this.buttons = [];
buttonModuleWatcher.watchContextBootstrap( ( bootstrap ) => {
const button = new GooglepayButton(
buttonModuleWatcher.watchContextBootstrap( async ( bootstrap ) => {
this.contextHandler = ContextHandlerFactory.create(
bootstrap.context,
buttonConfig,
ppcpConfig,
bootstrap.handler
);
const button = GooglepayButton.createButton(
bootstrap.context,
bootstrap.handler,
buttonConfig,
ppcpConfig
ppcpConfig,
this.contextHandler
);
this.buttons.push( button );
if ( this.googlePayConfig ) {
button.init( this.googlePayConfig );
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 ) {
initButton();
} else {
await this.init();
if ( this.googlePayConfig && this.transactionInfo ) {
initButton();
}
}
} );
}
init() {
( async () => {
// Gets GooglePay configuration of the PayPal merchant.
this.googlePayConfig = await paypal.Googlepay().config();
for ( const button of this.buttons ) {
button.init( this.googlePayConfig );
async init() {
try {
if ( ! this.googlePayConfig ) {
// Gets GooglePay configuration of the PayPal merchant.
this.googlePayConfig = await paypal.Googlepay().config();
}
} )();
if ( ! this.transactionInfo ) {
this.transactionInfo = await this.fetchTransactionInfo();
}
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 );
}
}
async fetchTransactionInfo() {
try {
if ( ! this.contextHandler ) {
throw new Error( 'ContextHandler is not initialized' );
}
return await this.contextHandler.transactionInfo();
} catch ( error ) {
console.error( 'Error fetching transaction info:', error );
throw error;
}
}
reinit() {

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

@ -0,0 +1,31 @@
import { LocalStorage } from '../../../../ppcp-button/resources/js/modules/Helper/LocalStorage';
export class GooglePayStorage extends LocalStorage {
static PAYER = 'payer';
static PAYER_TTL = 900; // 15 minutes in seconds
constructor() {
super( 'ppcp-googlepay' );
}
getPayer() {
return this.get( GooglePayStorage.PAYER );
}
setPayer( data ) {
/*
* The payer details are deleted on successful checkout, or after the TTL is reached.
* This helps to remove stale data from the browser, in case the customer chooses to
* use a different method to complete the purchase.
*/
this.set( GooglePayStorage.PAYER, data, GooglePayStorage.PAYER_TTL );
}
clearPayer() {
this.clear( GooglePayStorage.PAYER );
}
}
const moduleStorage = new GooglePayStorage();
export default moduleStorage;

View file

@ -1,28 +1,62 @@
/**
* Initialize the GooglePay module in the front end.
* In some cases, this module is loaded when the `window.PayPalCommerceGateway` object is not
* present. In that case, the page does not contain a Google Pay button, but some other logic
* that is related to Google Pay (e.g., the CheckoutBootstrap module)
*
* @file
*/
import { loadCustomScript } from '@paypal/paypal-js';
import { loadPaypalScript } from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading';
import GooglepayManager from './GooglepayManager';
import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper';
import { CheckoutBootstrap } from './ContextBootstrap/CheckoutBootstrap';
import moduleStorage from './Helper/GooglePayStorage';
( function ( { buttonConfig, ppcpConfig, jQuery } ) {
let manager;
( function ( { buttonConfig, ppcpConfig = {} } ) {
const context = ppcpConfig.context;
const bootstrap = function () {
manager = new GooglepayManager( buttonConfig, ppcpConfig );
manager.init();
};
setupButtonEvents( function () {
if ( manager ) {
manager.reinit();
function bootstrapPayButton() {
if ( ! buttonConfig || ! ppcpConfig ) {
return;
}
} );
const manager = new GooglepayManager( buttonConfig, ppcpConfig );
manager.init();
setupButtonEvents( function () {
manager.reinit();
} );
}
function bootstrapCheckout() {
if ( context && ! [ 'continuation', 'checkout' ].includes( context ) ) {
// Context must be missing/empty, or "continuation"/"checkout" to proceed.
return;
}
if ( ! CheckoutBootstrap.isPageWithCheckoutForm() ) {
return;
}
const checkoutBootstrap = new CheckoutBootstrap( moduleStorage );
checkoutBootstrap.init();
}
function bootstrap() {
bootstrapPayButton();
bootstrapCheckout();
}
document.addEventListener( 'DOMContentLoaded', () => {
if (
typeof buttonConfig === 'undefined' ||
typeof ppcpConfig === 'undefined'
) {
// No PayPal buttons present on this page.
if ( ! buttonConfig || ! ppcpConfig ) {
/*
* No PayPal buttons present on this page, but maybe a bootstrap module needs to be
* initialized. Skip loading the SDK or gateway configuration, and directly initialize
* the module.
*/
bootstrap();
return;
}
@ -52,5 +86,4 @@ import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Hel
} )( {
buttonConfig: window.wc_ppcp_googlepay,
ppcpConfig: window.PayPalCommerceGateway,
jQuery: window.jQuery,
} );