mirror of
https://github.com/woocommerce/woocommerce-paypal-payments.git
synced 2025-09-05 08:59:14 +08:00
Merge pull request #2525 from woocommerce/PCP-3525-google-pay-shipping-callback-active-for-virtual-product
Google Pay billing data without shipping callback (3525)
This commit is contained in:
commit
87e8aed779
10 changed files with 790 additions and 180 deletions
179
modules/ppcp-button/resources/js/modules/Helper/LocalStorage.js
Normal file
179
modules/ppcp-button/resources/js/modules/Helper/LocalStorage.js
Normal file
|
@ -0,0 +1,179 @@
|
|||
/* global localStorage */
|
||||
|
||||
function checkLocalStorageAvailability() {
|
||||
try {
|
||||
const testKey = '__ppcp_test__';
|
||||
localStorage.setItem( testKey, 'test' );
|
||||
localStorage.removeItem( testKey );
|
||||
return true;
|
||||
} catch ( e ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeKey( name ) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace( /[^a-z0-9_-]/g, '_' );
|
||||
}
|
||||
|
||||
function deserializeEntry( serialized ) {
|
||||
try {
|
||||
const payload = JSON.parse( serialized );
|
||||
|
||||
return {
|
||||
data: payload.data,
|
||||
expires: payload.expires || 0,
|
||||
};
|
||||
} catch ( e ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeEntry( data, timeToLive ) {
|
||||
const payload = {
|
||||
data,
|
||||
expires: calculateExpiration( timeToLive ),
|
||||
};
|
||||
|
||||
return JSON.stringify( payload );
|
||||
}
|
||||
|
||||
function calculateExpiration( timeToLive ) {
|
||||
return timeToLive ? Date.now() + timeToLive * 1000 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable class for handling data storage in the browser's local storage,
|
||||
* with optional expiration.
|
||||
*
|
||||
* Can be extended for module specific logic.
|
||||
*
|
||||
* @see GooglePaySession
|
||||
*/
|
||||
export class LocalStorage {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#group = '';
|
||||
|
||||
/**
|
||||
* @type {null|boolean}
|
||||
*/
|
||||
#canUseLocalStorage = null;
|
||||
|
||||
/**
|
||||
* @param {string} group - Group name for all storage keys managed by this instance.
|
||||
*/
|
||||
constructor( group ) {
|
||||
this.#group = sanitizeKey( group ) + ':';
|
||||
this.#removeExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all items in the current group that have reached the expiry date.
|
||||
*/
|
||||
#removeExpired() {
|
||||
if ( ! this.canUseLocalStorage ) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys( localStorage ).forEach( ( key ) => {
|
||||
if ( ! key.startsWith( this.#group ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = deserializeEntry( localStorage.getItem( key ) );
|
||||
if ( entry && entry.expires > 0 && entry.expires < Date.now() ) {
|
||||
localStorage.removeItem( key );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given entry name and adds the group prefix.
|
||||
*
|
||||
* @throws {Error} If the name is empty after sanitization.
|
||||
* @param {string} name - Entry name.
|
||||
* @return {string} Prefixed and sanitized entry name.
|
||||
*/
|
||||
#entryKey( name ) {
|
||||
const sanitizedName = sanitizeKey( name );
|
||||
|
||||
if ( sanitizedName.length === 0 ) {
|
||||
throw new Error( 'Name cannot be empty after sanitization' );
|
||||
}
|
||||
|
||||
return `${ this.#group }${ sanitizedName }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates, whether localStorage is available.
|
||||
*
|
||||
* @return {boolean} True means the localStorage API is available.
|
||||
*/
|
||||
get canUseLocalStorage() {
|
||||
if ( null === this.#canUseLocalStorage ) {
|
||||
this.#canUseLocalStorage = checkLocalStorageAvailability();
|
||||
}
|
||||
|
||||
return this.#canUseLocalStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores data in the browser's local storage, with an optional timeout.
|
||||
*
|
||||
* @param {string} name - Name of the item in the storage.
|
||||
* @param {any} data - The data to store.
|
||||
* @param {number} [timeToLive=0] - Lifespan in seconds. 0 means the data won't expire.
|
||||
* @throws {Error} If local storage is not available.
|
||||
*/
|
||||
set( name, data, timeToLive = 0 ) {
|
||||
if ( ! this.canUseLocalStorage ) {
|
||||
throw new Error( 'Local storage is not available' );
|
||||
}
|
||||
|
||||
const entry = serializeEntry( data, timeToLive );
|
||||
const entryKey = this.#entryKey( name );
|
||||
|
||||
localStorage.setItem( entryKey, entry );
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves previously stored data from the browser's local storage.
|
||||
*
|
||||
* @param {string} name - Name of the stored item.
|
||||
* @return {any|null} The stored data, or null when no valid entry is found or it has expired.
|
||||
* @throws {Error} If local storage is not available.
|
||||
*/
|
||||
get( name ) {
|
||||
if ( ! this.canUseLocalStorage ) {
|
||||
throw new Error( 'Local storage is not available' );
|
||||
}
|
||||
|
||||
const itemKey = this.#entryKey( name );
|
||||
const entry = deserializeEntry( localStorage.getItem( itemKey ) );
|
||||
|
||||
if ( ! entry ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the specified entry from the browser's local storage.
|
||||
*
|
||||
* @param {string} name - Name of the stored item.
|
||||
* @throws {Error} If local storage is not available.
|
||||
*/
|
||||
clear( name ) {
|
||||
if ( ! this.canUseLocalStorage ) {
|
||||
throw new Error( 'Local storage is not available' );
|
||||
}
|
||||
|
||||
const itemKey = this.#entryKey( name );
|
||||
localStorage.removeItem( itemKey );
|
||||
}
|
||||
}
|
|
@ -1,59 +1,196 @@
|
|||
export const payerData = () => {
|
||||
const payer = PayPalCommerceGateway.payer;
|
||||
/**
|
||||
* Name details.
|
||||
*
|
||||
* @typedef {Object} NameDetails
|
||||
* @property {string} [given_name] - First name, e.g. "John".
|
||||
* @property {string} [surname] - Last name, e.g. "Doe".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Postal address details.
|
||||
*
|
||||
* @typedef {Object} AddressDetails
|
||||
* @property {string} [country_code] - Country code (2-letter).
|
||||
* @property {string} [address_line_1] - Address details, line 1 (street, house number).
|
||||
* @property {string} [address_line_2] - Address details, line 2.
|
||||
* @property {string} [admin_area_1] - State or region.
|
||||
* @property {string} [admin_area_2] - State or region.
|
||||
* @property {string} [postal_code] - Zip code.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Phone details.
|
||||
*
|
||||
* @typedef {Object} PhoneDetails
|
||||
* @property {string} [phone_type] - Type, usually 'HOME'
|
||||
* @property {{national_number: string}} [phone_number] - Phone number details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Payer details.
|
||||
*
|
||||
* @typedef {Object} PayerDetails
|
||||
* @property {string} [email_address] - Email address for billing communication.
|
||||
* @property {PhoneDetails} [phone] - Phone number for billing communication.
|
||||
* @property {NameDetails} [name] - Payer's name.
|
||||
* @property {AddressDetails} [address] - Postal billing address.
|
||||
*/
|
||||
|
||||
// Map checkout fields to PayerData object properties.
|
||||
const FIELD_MAP = {
|
||||
'#billing_email': [ 'email_address' ],
|
||||
'#billing_last_name': [ 'name', 'surname' ],
|
||||
'#billing_first_name': [ 'name', 'given_name' ],
|
||||
'#billing_country': [ 'address', 'country_code' ],
|
||||
'#billing_address_1': [ 'address', 'address_line_1' ],
|
||||
'#billing_address_2': [ 'address', 'address_line_2' ],
|
||||
'#billing_state': [ 'address', 'admin_area_1' ],
|
||||
'#billing_city': [ 'address', 'admin_area_2' ],
|
||||
'#billing_postcode': [ 'address', 'postal_code' ],
|
||||
'#billing_phone': [ 'phone' ],
|
||||
};
|
||||
|
||||
function normalizePayerDetails( details ) {
|
||||
return {
|
||||
email_address: details.email_address,
|
||||
phone: details.phone,
|
||||
name: {
|
||||
surname: details.name?.surname,
|
||||
given_name: details.name?.given_name,
|
||||
},
|
||||
address: {
|
||||
country_code: details.address?.country_code,
|
||||
address_line_1: details.address?.address_line_1,
|
||||
address_line_2: details.address?.address_line_2,
|
||||
admin_area_1: details.address?.admin_area_1,
|
||||
admin_area_2: details.address?.admin_area_2,
|
||||
postal_code: details.address?.postal_code,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergePayerDetails( firstPayer, secondPayer ) {
|
||||
const mergeNestedObjects = ( target, source ) => {
|
||||
for ( const [ key, value ] of Object.entries( source ) ) {
|
||||
if ( null !== value && undefined !== value ) {
|
||||
if ( 'object' === typeof value ) {
|
||||
target[ key ] = mergeNestedObjects(
|
||||
target[ key ] || {},
|
||||
value
|
||||
);
|
||||
} else {
|
||||
target[ key ] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
return mergeNestedObjects(
|
||||
normalizePayerDetails( firstPayer ),
|
||||
normalizePayerDetails( secondPayer )
|
||||
);
|
||||
}
|
||||
|
||||
function getCheckoutBillingDetails() {
|
||||
const getElementValue = ( selector ) =>
|
||||
document.querySelector( selector )?.value;
|
||||
|
||||
const setNestedValue = ( obj, path, value ) => {
|
||||
let current = obj;
|
||||
for ( let i = 0; i < path.length - 1; i++ ) {
|
||||
current = current[ path[ i ] ] = current[ path[ i ] ] || {};
|
||||
}
|
||||
current[ path[ path.length - 1 ] ] = value;
|
||||
};
|
||||
|
||||
const data = {};
|
||||
|
||||
Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => {
|
||||
const value = getElementValue( selector );
|
||||
if ( value ) {
|
||||
setNestedValue( data, path, value );
|
||||
}
|
||||
} );
|
||||
|
||||
if ( data.phone && 'string' === typeof data.phone ) {
|
||||
data.phone = {
|
||||
phone_type: 'HOME',
|
||||
phone_number: { national_number: data.phone },
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function setCheckoutBillingDetails( payer ) {
|
||||
const setValue = ( path, field, value ) => {
|
||||
if ( null === value || undefined === value || ! field ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'phone' === path[ 0 ] && 'object' === typeof value ) {
|
||||
value = value.phone_number?.national_number;
|
||||
}
|
||||
|
||||
field.value = value;
|
||||
};
|
||||
|
||||
const getNestedValue = ( obj, path ) =>
|
||||
path.reduce( ( current, key ) => current?.[ key ], obj );
|
||||
|
||||
Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => {
|
||||
const value = getNestedValue( payer, path );
|
||||
const element = document.querySelector( selector );
|
||||
|
||||
setValue( path, element, value );
|
||||
} );
|
||||
}
|
||||
|
||||
export function getWooCommerceCustomerDetails() {
|
||||
// Populated on server-side with details about the current WooCommerce customer.
|
||||
return window?.PayPalCommerceGateway?.payer;
|
||||
}
|
||||
|
||||
export function getSessionBillingDetails() {
|
||||
// Populated by JS via `setSessionBillingDetails()`
|
||||
return window._PpcpPayerSessionDetails;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores customer details in the current JS context for use in the same request.
|
||||
* Details that are set are not persisted during navigation.
|
||||
*
|
||||
* @param {unknown} details - New payer details
|
||||
*/
|
||||
export function setSessionBillingDetails( details ) {
|
||||
if ( ! details || 'object' !== typeof details ) {
|
||||
return;
|
||||
}
|
||||
|
||||
window._PpcpPayerSessionDetails = normalizePayerDetails( details );
|
||||
}
|
||||
|
||||
export function payerData() {
|
||||
const payer = getWooCommerceCustomerDetails() ?? getSessionBillingDetails();
|
||||
|
||||
if ( ! payer ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phone =
|
||||
document.querySelector( '#billing_phone' ) ||
|
||||
typeof payer.phone !== 'undefined'
|
||||
? {
|
||||
phone_type: 'HOME',
|
||||
phone_number: {
|
||||
national_number: document.querySelector(
|
||||
'#billing_phone'
|
||||
)
|
||||
? document.querySelector( '#billing_phone' ).value
|
||||
: payer.phone.phone_number.national_number,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const payerData = {
|
||||
email_address: document.querySelector( '#billing_email' )
|
||||
? document.querySelector( '#billing_email' ).value
|
||||
: payer.email_address,
|
||||
name: {
|
||||
surname: document.querySelector( '#billing_last_name' )
|
||||
? document.querySelector( '#billing_last_name' ).value
|
||||
: payer.name.surname,
|
||||
given_name: document.querySelector( '#billing_first_name' )
|
||||
? document.querySelector( '#billing_first_name' ).value
|
||||
: payer.name.given_name,
|
||||
},
|
||||
address: {
|
||||
country_code: document.querySelector( '#billing_country' )
|
||||
? document.querySelector( '#billing_country' ).value
|
||||
: payer.address.country_code,
|
||||
address_line_1: document.querySelector( '#billing_address_1' )
|
||||
? document.querySelector( '#billing_address_1' ).value
|
||||
: payer.address.address_line_1,
|
||||
address_line_2: document.querySelector( '#billing_address_2' )
|
||||
? document.querySelector( '#billing_address_2' ).value
|
||||
: payer.address.address_line_2,
|
||||
admin_area_1: document.querySelector( '#billing_state' )
|
||||
? document.querySelector( '#billing_state' ).value
|
||||
: payer.address.admin_area_1,
|
||||
admin_area_2: document.querySelector( '#billing_city' )
|
||||
? document.querySelector( '#billing_city' ).value
|
||||
: payer.address.admin_area_2,
|
||||
postal_code: document.querySelector( '#billing_postcode' )
|
||||
? document.querySelector( '#billing_postcode' ).value
|
||||
: payer.address.postal_code,
|
||||
},
|
||||
};
|
||||
const formData = getCheckoutBillingDetails();
|
||||
|
||||
if ( phone ) {
|
||||
payerData.phone = phone;
|
||||
if ( formData ) {
|
||||
return mergePayerDetails( payer, formData );
|
||||
}
|
||||
return payerData;
|
||||
};
|
||||
|
||||
return normalizePayerDetails( payer );
|
||||
}
|
||||
|
||||
export function setPayerData( payerDetails, updateCheckoutForm = false ) {
|
||||
setSessionBillingDetails( payerDetails );
|
||||
|
||||
if ( updateCheckoutForm ) {
|
||||
setCheckoutBillingDetails( payerDetails );
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,31 +1,49 @@
|
|||
const onApprove = ( context, errorHandler ) => {
|
||||
return ( data, actions ) => {
|
||||
const canCreateOrder =
|
||||
! context.config.vaultingEnabled || data.paymentSource !== 'venmo';
|
||||
|
||||
const payload = {
|
||||
nonce: context.config.ajax.approve_order.nonce,
|
||||
order_id: data.orderID,
|
||||
funding_source: window.ppcpFundingSource,
|
||||
should_create_wc_order: canCreateOrder,
|
||||
};
|
||||
|
||||
if ( canCreateOrder && data.payer ) {
|
||||
payload.payer = data.payer;
|
||||
}
|
||||
|
||||
return fetch( context.config.ajax.approve_order.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify( {
|
||||
nonce: context.config.ajax.approve_order.nonce,
|
||||
order_id: data.orderID,
|
||||
funding_source: window.ppcpFundingSource,
|
||||
should_create_wc_order:
|
||||
! context.config.vaultingEnabled ||
|
||||
data.paymentSource !== 'venmo',
|
||||
} ),
|
||||
body: JSON.stringify( payload ),
|
||||
} )
|
||||
.then( ( res ) => {
|
||||
return res.json();
|
||||
} )
|
||||
.then( ( data ) => {
|
||||
if ( ! data.success ) {
|
||||
location.href = context.config.redirect;
|
||||
.then( ( approveData ) => {
|
||||
if ( ! approveData.success ) {
|
||||
errorHandler.genericError();
|
||||
return actions.restart().catch( ( err ) => {
|
||||
errorHandler.genericError();
|
||||
} );
|
||||
}
|
||||
|
||||
const orderReceivedUrl = data.data?.order_received_url;
|
||||
const orderReceivedUrl = approveData.data?.order_received_url;
|
||||
|
||||
location.href = orderReceivedUrl
|
||||
/**
|
||||
* Notice how this step initiates a redirect to a new page using a plain
|
||||
* URL as new location. This process does not send any details about the
|
||||
* approved order or billed customer.
|
||||
* Also, due to the redirect starting _instantly_ there should be no other
|
||||
* logic scheduled after calling `await onApprove()`;
|
||||
*/
|
||||
|
||||
window.location.href = orderReceivedUrl
|
||||
? orderReceivedUrl
|
||||
: context.config.redirect;
|
||||
} );
|
||||
|
|
|
@ -625,6 +625,15 @@ export default class PaymentButton {
|
|||
this.#logger.error( ...args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or close a log-group
|
||||
*
|
||||
* @param {?string} [label=null] Group label.
|
||||
*/
|
||||
logGroup( label = null ) {
|
||||
this.#logger.group( label );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -6,6 +6,8 @@ import PaymentButton from '../../../ppcp-button/resources/js/modules/Renderer/Pa
|
|||
import widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder';
|
||||
import UpdatePaymentData from './Helper/UpdatePaymentData';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Plugin-specific styling.
|
||||
|
@ -39,11 +41,17 @@ import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper
|
|||
*
|
||||
* @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.
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -53,14 +61,40 @@ import { PaymentMethods } from '../../../ppcp-button/resources/js/modules/Helper
|
|||
* @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.
|
||||
* @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
|
||||
|
@ -78,7 +112,7 @@ class GooglepayButton extends PaymentButton {
|
|||
#paymentsClient = null;
|
||||
|
||||
/**
|
||||
* Details about the processed transaction.
|
||||
* Details about the processed transaction, provided to the Google SDK.
|
||||
*
|
||||
* @type {?TransactionInfo}
|
||||
*/
|
||||
|
@ -388,12 +422,14 @@ class GooglepayButton extends PaymentButton {
|
|||
const initiatePaymentRequest = () => {
|
||||
window.ppcpFundingSource = 'googlepay';
|
||||
const paymentDataRequest = this.paymentDataRequest();
|
||||
|
||||
this.log(
|
||||
'onButtonClick: paymentDataRequest',
|
||||
paymentDataRequest,
|
||||
this.context
|
||||
);
|
||||
this.paymentsClient.loadPaymentData( paymentDataRequest );
|
||||
|
||||
return this.paymentsClient.loadPaymentData( paymentDataRequest );
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
|
@ -434,28 +470,24 @@ class GooglepayButton extends PaymentButton {
|
|||
apiVersionMinor: 0,
|
||||
};
|
||||
|
||||
const googlePayConfig = this.googlePayConfig;
|
||||
const paymentDataRequest = Object.assign( {}, baseRequest );
|
||||
paymentDataRequest.allowedPaymentMethods =
|
||||
googlePayConfig.allowedPaymentMethods;
|
||||
paymentDataRequest.transactionInfo = this.transactionInfo;
|
||||
paymentDataRequest.merchantInfo = googlePayConfig.merchantInfo;
|
||||
const useShippingCallback = this.requiresShipping;
|
||||
const callbackIntents = [ 'PAYMENT_AUTHORIZATION' ];
|
||||
|
||||
if ( this.requiresShipping ) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
//------------------------
|
||||
|
@ -543,83 +575,111 @@ class GooglepayButton extends PaymentButton {
|
|||
//------------------------
|
||||
|
||||
onPaymentAuthorized( paymentData ) {
|
||||
this.log( 'onPaymentAuthorized' );
|
||||
this.log( 'onPaymentAuthorized', paymentData );
|
||||
|
||||
return this.processPayment( paymentData );
|
||||
}
|
||||
|
||||
async processPayment( paymentData ) {
|
||||
this.log( 'processPayment' );
|
||||
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 );
|
||||
const paymentError = ( reason ) => {
|
||||
this.error( reason );
|
||||
|
||||
return this.processPaymentResponse(
|
||||
'ERROR',
|
||||
'PAYMENT_AUTHORIZATION',
|
||||
reason
|
||||
);
|
||||
};
|
||||
|
||||
const checkPayPalApproval = async ( orderId ) => {
|
||||
const confirmationData = {
|
||||
orderId,
|
||||
paymentMethodData: paymentData.paymentMethodData,
|
||||
};
|
||||
|
||||
const confirmOrderResponse = await widgetBuilder.paypal
|
||||
.Googlepay()
|
||||
.confirmOrder( {
|
||||
orderId: id,
|
||||
paymentMethodData: paymentData.paymentMethodData,
|
||||
} );
|
||||
.confirmOrder( confirmationData );
|
||||
|
||||
this.log(
|
||||
'processPayment: confirmOrder',
|
||||
confirmOrderResponse
|
||||
);
|
||||
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 );
|
||||
|
||||
/** Capture the Order on the Server */
|
||||
if ( confirmOrderResponse.status === 'APPROVED' ) {
|
||||
let approveFailed = false;
|
||||
await this.contextHandler.approveOrder(
|
||||
{ orderID, payer },
|
||||
{
|
||||
orderID: id,
|
||||
},
|
||||
{
|
||||
// actions mock object.
|
||||
restart: () =>
|
||||
new Promise( ( resolve, reject ) => {
|
||||
approveFailed = true;
|
||||
new Promise( ( resolve ) => {
|
||||
isApproved = false;
|
||||
resolve();
|
||||
} ),
|
||||
order: {
|
||||
get: () =>
|
||||
new Promise( ( resolve, reject ) => {
|
||||
new Promise( ( resolve ) => {
|
||||
resolve( null );
|
||||
} ),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! approveFailed ) {
|
||||
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(
|
||||
this.processPaymentResponse(
|
||||
'ERROR',
|
||||
'PAYMENT_AUTHORIZATION',
|
||||
'FAILED TO APPROVE'
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
resolve(
|
||||
this.processPaymentResponse(
|
||||
'ERROR',
|
||||
'PAYMENT_AUTHORIZATION',
|
||||
'TRANSACTION FAILED'
|
||||
)
|
||||
);
|
||||
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(
|
||||
this.processPaymentResponse(
|
||||
'ERROR',
|
||||
'PAYMENT_AUTHORIZATION',
|
||||
err.message
|
||||
)
|
||||
);
|
||||
resolve( paymentError( err.message ) );
|
||||
}
|
||||
|
||||
this.logGroup();
|
||||
} );
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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 );
|
||||
function bootstrapPayButton() {
|
||||
if ( ! buttonConfig || ! ppcpConfig ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new GooglepayManager( buttonConfig, ppcpConfig );
|
||||
manager.init();
|
||||
};
|
||||
|
||||
setupButtonEvents( function () {
|
||||
if ( manager ) {
|
||||
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,
|
||||
} );
|
||||
|
|
|
@ -100,11 +100,21 @@ class GooglepayModule implements ServiceModule, ExtendingModule, ExecutableModul
|
|||
static function () use ( $c, $button ) {
|
||||
$smart_button = $c->get( 'button.smart-button' );
|
||||
assert( $smart_button instanceof SmartButtonInterface );
|
||||
|
||||
if ( $smart_button->should_load_ppcp_script() ) {
|
||||
$button->enqueue();
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Checkout page, but no PPCP scripts were loaded. Most likely in continuation mode.
|
||||
* Need to enqueue some Google Pay scripts to populate the billing form with details
|
||||
* provided by Google Pay.
|
||||
*/
|
||||
if ( is_checkout() ) {
|
||||
$button->enqueue();
|
||||
}
|
||||
|
||||
if ( has_block( 'woocommerce/checkout' ) || has_block( 'woocommerce/cart' ) ) {
|
||||
/**
|
||||
* Should add this to the ButtonInterface.
|
||||
|
|
|
@ -18,6 +18,13 @@ export default class ConsoleLogger {
|
|||
*/
|
||||
#enabled = false;
|
||||
|
||||
/**
|
||||
* Tracks the current log-group that was started using `this.group()`
|
||||
*
|
||||
* @type {?string}
|
||||
*/
|
||||
#openGroup = null;
|
||||
|
||||
constructor( ...prefixes ) {
|
||||
if ( prefixes.length ) {
|
||||
this.#prefix = `[${ prefixes.join( ' | ' ) }]`;
|
||||
|
@ -55,4 +62,28 @@ export default class ConsoleLogger {
|
|||
error( ...args ) {
|
||||
console.error( this.#prefix, ...args );
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts or ends a group in the browser console.
|
||||
*
|
||||
* @param {string} [label=null] - The group label. Omit to end the current group.
|
||||
*/
|
||||
group( label = null ) {
|
||||
if ( ! this.#enabled ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! label || this.#openGroup ) {
|
||||
// eslint-disable-next-line
|
||||
console.groupEnd();
|
||||
this.#openGroup = null;
|
||||
}
|
||||
|
||||
if ( label ) {
|
||||
// eslint-disable-next-line
|
||||
console.group( label );
|
||||
|
||||
this.#openGroup = label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue