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:
Philipp Stracker 2024-09-06 14:50:29 +02:00 committed by GitHub
commit 87e8aed779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 790 additions and 180 deletions

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

View file

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

View file

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

View file

@ -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.