woocommerce-paypal-payments/modules/ppcp-googlepay/resources/js/GooglepayButton.js
Philipp Stracker f2301a4674
🐛 Fix price calculation on pages with cart
This covers: Mini-Cart, Classic Cart, Block Cart, Classic Checkout, Block Checkout, Pay-Now page
2024-08-13 20:02:49 +02:00

610 lines
15 KiB
JavaScript

import { setVisible } from '../../../ppcp-button/resources/js/modules/Helper/Hiding';
import { setEnabled } from '../../../ppcp-button/resources/js/modules/Helper/ButtonDisabler';
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 TransactionInfo from './Helper/TransactionInfo';
class GooglepayButton {
/**
* @type {TransactionInfo}
*/
transactionInfo;
constructor(
context,
externalHandler,
buttonConfig,
ppcpConfig,
contextHandler
) {
apmButtonsInit( ppcpConfig );
this.isInitialized = false;
this.context = context;
this.externalHandler = externalHandler;
this.buttonConfig = buttonConfig;
this.ppcpConfig = ppcpConfig;
this.contextHandler = contextHandler;
this.paymentsClient = null;
this.log = function () {
if ( this.buttonConfig.is_debug ) {
//console.log('[GooglePayButton]', ...arguments);
}
};
}
init( config, transactionInfo ) {
if ( this.isInitialized ) {
return;
}
this.isInitialized = true;
if ( ! this.validateConfig() ) {
return;
}
if ( ! this.contextHandler.validateContext() ) {
return;
}
this.googlePayConfig = config;
this.transactionInfo = transactionInfo;
this.allowedPaymentMethods = config.allowedPaymentMethods;
this.baseCardPaymentMethod = this.allowedPaymentMethods[ 0 ];
this.initClient();
this.initEventHandlers();
this.paymentsClient
.isReadyToPay(
this.buildReadyToPayRequest(
this.allowedPaymentMethods,
config
)
)
.then( ( response ) => {
if ( response.result ) {
if (
( this.context === 'checkout' ||
this.context === 'pay-now' ) &&
this.buttonConfig.is_wc_gateway_enabled === '1'
) {
const wrapper = document.getElementById(
'ppc-button-ppcp-googlepay'
);
if ( wrapper ) {
const { ppcpStyle, buttonStyle } =
this.contextConfig();
wrapper.classList.add(
`ppcp-button-${ ppcpStyle.shape }`,
'ppcp-button-apm',
'ppcp-button-googlepay'
);
if ( ppcpStyle.height ) {
wrapper.style.height = `${ ppcpStyle.height }px`;
}
this.addButtonCheckout(
this.baseCardPaymentMethod,
wrapper,
buttonStyle
);
return;
}
}
this.addButton( this.baseCardPaymentMethod );
}
} )
.catch( function ( err ) {
console.error( err );
} );
}
reinit() {
if ( ! this.googlePayConfig ) {
return;
}
this.isInitialized = false;
this.init( this.googlePayConfig, this.transactionInfo );
}
validateConfig() {
if (
[ 'PRODUCTION', 'TEST' ].indexOf(
this.buttonConfig.environment
) === -1
) {
console.error(
'[GooglePayButton] Invalid environment.',
this.buttonConfig.environment
);
return false;
}
if ( ! this.contextHandler ) {
console.error(
'[GooglePayButton] Invalid context handler.',
this.contextHandler
);
return false;
}
return true;
}
/**
* Returns configurations relative to this button context.
*/
contextConfig() {
const config = {
wrapper: this.buttonConfig.button.wrapper,
ppcpStyle: this.ppcpConfig.button.style,
buttonStyle: this.buttonConfig.button.style,
ppcpButtonWrapper: this.ppcpConfig.button.wrapper,
};
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;
}
initClient() {
const callbacks = {
onPaymentAuthorized: this.onPaymentAuthorized.bind( this ),
};
if (
this.buttonConfig.shipping.enabled &&
this.contextHandler.shippingAllowed()
) {
callbacks.onPaymentDataChanged =
this.onPaymentDataChanged.bind( this );
}
this.paymentsClient = new google.payments.api.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 ) {
return Object.assign( {}, baseRequest, {
allowedPaymentMethods,
} );
}
/**
* Add a Google Pay purchase button
* @param baseCardPaymentMethod
*/
addButton( baseCardPaymentMethod ) {
this.log( 'addButton', this.context );
const { wrapper, ppcpStyle, buttonStyle } = this.contextConfig();
this.waitForWrapper( wrapper, () => {
jQuery( wrapper ).addClass( 'ppcp-button-' + ppcpStyle.shape );
if ( ppcpStyle.height ) {
jQuery( wrapper ).css( 'height', `${ ppcpStyle.height }px` );
}
const button = this.paymentsClient.createButton( {
onClick: this.onButtonClick.bind( this ),
allowedPaymentMethods: [ baseCardPaymentMethod ],
buttonColor: buttonStyle.color || 'black',
buttonType: buttonStyle.type || 'pay',
buttonLocale: buttonStyle.language || 'en',
buttonSizeMode: 'fill',
} );
jQuery( wrapper ).append( button );
} );
}
addButtonCheckout( baseCardPaymentMethod, wrapper, buttonStyle ) {
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.appendChild( button );
}
waitForWrapper( selector, callback, delay = 100, timeout = 2000 ) {
const startTime = Date.now();
const interval = setInterval( () => {
const el = document.querySelector( selector );
const timeElapsed = Date.now() - startTime;
if ( el ) {
clearInterval( interval );
callback( el );
} else if ( timeElapsed > timeout ) {
clearInterval( interval );
}
}, delay );
}
//------------------------
// Button click
//------------------------
/**
* Show Google Pay payment sheet when Google Pay payment button is clicked
*/
onButtonClick() {
this.log( 'onButtonClick', this.context );
const initiatePaymentRequest = () => {
window.ppcpFundingSource = 'googlepay';
const paymentDataRequest = this.paymentDataRequest();
this.log(
'onButtonClick: paymentDataRequest',
paymentDataRequest,
this.context
);
this.paymentsClient.loadPaymentData( paymentDataRequest );
};
if ( 'function' === typeof this.contextHandler.validateForm ) {
// During regular checkout, validate the checkout form before initiating the payment.
this.contextHandler
.validateForm()
.then( initiatePaymentRequest, () => {
console.error(
'[GooglePayButton] Form validation failed.'
);
} );
} else {
// This is the flow on product page, cart, and other non-checkout pages.
initiatePaymentRequest();
}
}
paymentDataRequest() {
const baseRequest = {
apiVersion: 2,
apiVersionMinor: 0,
};
const googlePayConfig = this.googlePayConfig;
const paymentDataRequest = Object.assign( {}, baseRequest );
paymentDataRequest.allowedPaymentMethods =
googlePayConfig.allowedPaymentMethods;
paymentDataRequest.transactionInfo = this.transactionInfo.dataObject;
paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo;
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' ];
}
return paymentDataRequest;
}
//------------------------
// Shipping processing
//------------------------
shippingAddressParameters() {
return {
allowedCountryCodes: this.buttonConfig.shipping.countries,
phoneNumberRequired: true,
};
}
onPaymentDataChanged( paymentData ) {
this.log( 'onPaymentDataChanged', this.context );
this.log( 'paymentData', paymentData );
return new Promise( async ( resolve, reject ) => {
try {
const paymentDataRequestUpdate = {};
const updatedData = await new UpdatePaymentData(
this.buttonConfig.ajax.update_payment_data
).update( paymentData );
const transactionInfo = this.transactionInfo;
// Check, if the current context uses the WC cart.
const hasRealCart = [
'checkout-block',
'checkout',
'cart-block',
'cart',
'mini-cart',
'pay-now',
].includes( this.context );
this.log( 'onPaymentDataChanged:updatedData', updatedData );
this.log(
'onPaymentDataChanged:transactionInfo',
transactionInfo
);
updatedData.country_code = transactionInfo.countryCode;
updatedData.currency_code = transactionInfo.currencyCode;
// Handle unserviceable address.
if ( ! updatedData.shipping_options?.shippingOptions?.length ) {
paymentDataRequestUpdate.error =
this.unserviceableShippingAddressError();
resolve( paymentDataRequestUpdate );
return;
}
if (
[ 'INITIALIZE', 'SHIPPING_ADDRESS' ].includes(
paymentData.callbackTrigger
)
) {
paymentDataRequestUpdate.newShippingOptionParameters =
updatedData.shipping_options;
}
if ( updatedData.total && hasRealCart ) {
transactionInfo.setTotal(
updatedData.total,
updatedData.shipping_fee
);
} else {
transactionInfo.shippingFee = this.getShippingCosts(
paymentData?.shippingOptionData?.id,
updatedData.shipping_options
);
}
paymentDataRequestUpdate.newTransactionInfo =
this.calculateNewTransactionInfo( transactionInfo );
resolve( paymentDataRequestUpdate );
} catch ( error ) {
console.error( 'Error during onPaymentDataChanged:', error );
reject( error );
}
} );
}
/**
* Returns the shipping costs as numeric value.
*
* TODO - Move this to the PaymentButton base class
*
* @param {string} shippingId - The shipping method ID.
* @param {Object} shippingData - The PaymentDataRequest object that
* contains shipping options.
* @param {Array} shippingData.shippingOptions
* @param {string} shippingData.defaultSelectedOptionId
*
* @return {number} The shipping costs.
*/
getShippingCosts(
shippingId,
{ shippingOptions = [], defaultSelectedOptionId = '' } = {}
) {
if ( ! shippingOptions?.length ) {
this.log( 'Cannot calculate shipping cost: No Shipping Options' );
return 0;
}
const findOptionById = ( id ) =>
shippingOptions.find( ( option ) => option.id === id );
const getValidShippingId = () => {
if (
'shipping_option_unselected' === shippingId ||
! findOptionById( shippingId )
) {
// Entered on initial call, and when changing the shipping country.
return defaultSelectedOptionId;
}
return shippingId;
};
const currentOption = findOptionById( getValidShippingId() );
return Number( currentOption?.cost ) || 0;
}
unserviceableShippingAddressError() {
return {
reason: 'SHIPPING_ADDRESS_UNSERVICEABLE',
message: 'Cannot ship to the selected address',
intent: 'SHIPPING_ADDRESS',
};
}
/**
* Recalculates and returns the plain transaction info object.
*
* @param {TransactionInfo} transactionInfo - Internal transactionInfo instance.
* @return {{totalPrice: string, countryCode: string, totalPriceStatus: string, currencyCode: string}} Updated details.
*/
calculateNewTransactionInfo( transactionInfo ) {
return transactionInfo.dataObject;
}
//------------------------
// Payment process
//------------------------
onPaymentAuthorized( paymentData ) {
this.log( 'onPaymentAuthorized', this.context );
return this.processPayment( paymentData );
}
async processPayment( paymentData ) {
this.log( 'processPayment', this.context );
return new Promise( async ( resolve, reject ) => {
try {
const id = await this.contextHandler.createOrder();
this.log( 'processPayment: createOrder', id, this.context );
const confirmOrderResponse = await widgetBuilder.paypal
.Googlepay()
.confirmOrder( {
orderId: id,
paymentMethodData: paymentData.paymentMethodData,
} );
this.log(
'processPayment: confirmOrder',
confirmOrderResponse,
this.context
);
/** 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 );
} ),
},
}
);
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'
)
);
}
} catch ( err ) {
resolve(
this.processPaymentResponse(
'ERROR',
'PAYMENT_AUTHORIZATION',
err.message
)
);
}
} );
}
processPaymentResponse( state, intent = null, message = null ) {
const response = {
transactionState: state,
};
if ( intent || message ) {
response.error = {
intent,
message,
};
}
this.log( 'processPaymentResponse', response, this.context );
return response;
}
}
export default GooglepayButton;