From 00e29597002c7aed3add7b863a97b0e7e392ab69 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Fri, 16 Aug 2024 15:41:05 +0200 Subject: [PATCH 01/17] =?UTF-8?q?=E2=9C=A8=20Get=20payee=20details=20witho?= =?UTF-8?q?ut=20shipping=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/js/modules/Helper/PayerData.js | 168 +++++++++---- .../resources/js/GooglepayButton.js | 230 ++++++++++-------- 2 files changed, 257 insertions(+), 141 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index b9b84d14f..da7eca4d6 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -1,59 +1,135 @@ +/** + * 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' ], +}; + +/** + * Returns billing details from the checkout form or global JS object. + * + * @return {?PayerDetails} Full billing details, or null on failure. + */ export const payerData = () => { - const payer = PayPalCommerceGateway.payer; + const payer = window.PayPalCommerceGateway?.payer; 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, + const getElementValue = ( selector ) => + document.querySelector( selector )?.value; + + // Initialize data with existing payer values. + const data = { + email_address: payer.email_address, + phone: payer.phone, 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, + surname: payer.name?.surname, + given_name: 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, + country_code: payer.address?.country_code, + address_line_1: payer.address?.address_line_1, + address_line_2: payer.address?.address_line_2, + admin_area_1: payer.address?.admin_area_1, + admin_area_2: payer.address?.admin_area_2, + postal_code: payer.address?.postal_code, }, }; - if ( phone ) { - payerData.phone = phone; + // Update data with DOM values where they exist. + Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => { + const value = getElementValue( selector ); + if ( value ) { + let current = data; + path.slice( 0, -1 ).forEach( ( key ) => { + current = current[ key ] = current[ key ] || {}; + } ); + current[ path[ path.length - 1 ] ] = value; + } + } ); + + // Handle phone separately due to its nested structure. + const phoneNumber = data.phone; + if ( phoneNumber && typeof phoneNumber === 'string' ) { + data.phone = { + phone_type: 'HOME', + phone_number: { national_number: phoneNumber }, + }; } - return payerData; + + return data; +}; + +/** + * Updates the DOM with specific payer details. + * + * @param {PayerDetails} newData - New payer details. + */ +export const setPayerData = ( newData ) => { + const setValue = ( path, field, value ) => { + if ( null === value || undefined === value || ! field ) { + return; + } + + if ( path[ 0 ] === 'phone' && typeof value === 'object' ) { + value = value.phone_number?.national_number; + } + + if ( field.value !== value ) { + field.value = value; + } + }; + + Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => { + const value = path.reduce( ( obj, key ) => obj?.[ key ], newData ); + const element = document.querySelector( selector ); + + setValue( path, element, value ); + } ); }; diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index 4b267c4e7..a9bfd693e 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -6,6 +6,7 @@ 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'; /** * Plugin-specific styling. @@ -39,11 +40,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,12 +60,18 @@ 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. */ class GooglepayButton extends PaymentButton { @@ -78,7 +91,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 +401,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 +449,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,82 +554,111 @@ class GooglepayButton extends PaymentButton { //------------------------ onPaymentAuthorized( paymentData ) { - this.log( 'onPaymentAuthorized' ); + this.log( 'onPaymentAuthorized', paymentData ); + return this.processPayment( paymentData ); } async processPayment( paymentData ) { this.log( 'processPayment' ); - return new Promise( async ( resolve, reject ) => { - try { - const id = await this.contextHandler.createOrder(); + const paymentError = ( reason ) => { + this.error( reason ); - this.log( 'processPayment: createOrder', id ); + return this.processPaymentResponse( + 'ERROR', + 'PAYMENT_AUTHORIZATION', + reason + ); + }; - const confirmOrderResponse = await widgetBuilder.paypal - .Googlepay() - .confirmOrder( { - orderId: id, - paymentMethodData: paymentData.paymentMethodData, - } ); + const checkPayPalApproval = async ( orderId ) => { + const confirmOrderResponse = await widgetBuilder.paypal + .Googlepay() + .confirmOrder( { + orderId, + paymentMethodData: paymentData.paymentMethodData, + } ); - this.log( - 'processPayment: confirmOrder', - confirmOrderResponse - ); + this.log( 'confirmOrder', confirmOrderResponse ); - /** 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 ); - } ), - }, - } - ); + return 'APPROVE' === confirmOrderResponse?.status; + }; - 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' - ) - ); + const approveOrderServerSide = async ( orderID ) => { + let isApproved = true; + + this.log( 'approveOrder', orderID ); + + await this.contextHandler.approveOrder( + { orderID }, + { + restart: () => + new Promise( ( resolve ) => { + isApproved = false; + resolve(); + } ), + order: { + get: () => + new Promise( ( resolve ) => { + resolve( null ); + } ), + }, } + ); + + 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; + } + + const success = await approveOrderServerSide( id ); + + if ( success ) { + resolve( this.processPaymentResponse( 'SUCCESS' ) ); + } else { + resolve( paymentError( 'FAILED TO APPROVE' ) ); + } + }; + + const propagatePayerDataToForm = () => { + const raw = paymentData?.paymentMethodData?.info?.billingAddress; + + const payer = { + 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, + }, + }; + + setPayerData( payer ); + }; + + return new Promise( async ( resolve ) => { + try { + propagatePayerDataToForm(); + await processPaymentPromise( resolve ); } catch ( err ) { - resolve( - this.processPaymentResponse( - 'ERROR', - 'PAYMENT_AUTHORIZATION', - err.message - ) - ); + resolve( paymentError( err.message ) ); } } ); } From 63e9c8bf27729d0616f2f8249130d0c107adb274 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Fri, 16 Aug 2024 18:15:59 +0200 Subject: [PATCH 02/17] =?UTF-8?q?=E2=9C=A8=20New=20ConsoleLogger=20`group`?= =?UTF-8?q?=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups subsequent console.log output, for cleaner console output --- .../js/modules/Renderer/PaymentButton.js | 9 ++++++ .../resources/js/GooglepayButton.js | 16 +++++----- .../resources/js/helper/ConsoleLogger.js | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js index 11fe31f79..143265bfb 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js @@ -599,6 +599,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. diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index a9bfd693e..cfd5d4b07 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -560,7 +560,7 @@ class GooglepayButton extends PaymentButton { } async processPayment( paymentData ) { - this.log( 'processPayment' ); + this.logGroup( 'processPayment' ); const paymentError = ( reason ) => { this.error( reason ); @@ -623,13 +623,13 @@ class GooglepayButton extends PaymentButton { return; } - const success = await approveOrderServerSide( id ); + const success = await approveOrderServerSide( id ); - if ( success ) { - resolve( this.processPaymentResponse( 'SUCCESS' ) ); - } else { - resolve( paymentError( 'FAILED TO APPROVE' ) ); - } + if ( success ) { + resolve( this.processPaymentResponse( 'SUCCESS' ) ); + } else { + resolve( paymentError( 'FAILED TO APPROVE' ) ); + } }; const propagatePayerDataToForm = () => { @@ -660,6 +660,8 @@ class GooglepayButton extends PaymentButton { } catch ( err ) { resolve( paymentError( err.message ) ); } + + this.logGroup(); } ); } diff --git a/modules/ppcp-wc-gateway/resources/js/helper/ConsoleLogger.js b/modules/ppcp-wc-gateway/resources/js/helper/ConsoleLogger.js index c76aa8960..a7b61b32a 100644 --- a/modules/ppcp-wc-gateway/resources/js/helper/ConsoleLogger.js +++ b/modules/ppcp-wc-gateway/resources/js/helper/ConsoleLogger.js @@ -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; + } + } } From c007d7909c2793b266eb733c565a7ec52b87af4b Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Fri, 16 Aug 2024 18:45:49 +0200 Subject: [PATCH 03/17] =?UTF-8?q?=E2=9C=A8=20Option=20to=20only=20set=20mi?= =?UTF-8?q?ssing=20billing=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/js/modules/Helper/PayerData.js | 10 +++++++--- modules/ppcp-googlepay/resources/js/GooglepayButton.js | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index da7eca4d6..1f97309c1 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -109,9 +109,13 @@ export const payerData = () => { /** * Updates the DOM with specific payer details. * - * @param {PayerDetails} newData - New payer details. + * @param {PayerDetails} newData - New payer details. + * @param {boolean} [overwriteExisting=false] - If set to true, all provided values will replace existing details. If false, or omitted, only undefined fields are updated. */ -export const setPayerData = ( newData ) => { +export const setPayerData = ( newData, overwriteExisting = false ) => { + // TODO: Check if we can add some kind of "filter" to allow customization of the data. + // Or add JS flags like "onlyUpdateMissing". + const setValue = ( path, field, value ) => { if ( null === value || undefined === value || ! field ) { return; @@ -121,7 +125,7 @@ export const setPayerData = ( newData ) => { value = value.phone_number?.national_number; } - if ( field.value !== value ) { + if ( overwriteExisting || ! field.value ) { field.value = value; } }; diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index cfd5d4b07..d518c1850 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -634,8 +634,8 @@ class GooglepayButton extends PaymentButton { const propagatePayerDataToForm = () => { const raw = paymentData?.paymentMethodData?.info?.billingAddress; - const payer = { + email_address: paymentData?.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 From cf83d2ba6efa6e254ba508939f675121f4cf2401 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Fri, 16 Aug 2024 18:54:55 +0200 Subject: [PATCH 04/17] =?UTF-8?q?=F0=9F=90=9B=20Fix=20critical=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ppcp-googlepay/resources/js/GooglepayButton.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index d518c1850..d8a910f34 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -573,16 +573,18 @@ class GooglepayButton extends PaymentButton { }; const checkPayPalApproval = async ( orderId ) => { + const confirmationData = { + orderId, + paymentMethodData: paymentData.paymentMethodData, + }; + const confirmOrderResponse = await widgetBuilder.paypal .Googlepay() - .confirmOrder( { - orderId, - paymentMethodData: paymentData.paymentMethodData, - } ); + .confirmOrder( confirmationData ); this.log( 'confirmOrder', confirmOrderResponse ); - return 'APPROVE' === confirmOrderResponse?.status; + return 'APPROVED' === confirmOrderResponse?.status; }; const approveOrderServerSide = async ( orderID ) => { From 451dc842ed506fe6b48b10e7fad4e128fe050fef Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 20 Aug 2024 13:47:00 +0200 Subject: [PATCH 05/17] =?UTF-8?q?=E2=9C=A8=20New=20LocalStorage=20module?= =?UTF-8?q?=20for=20Google=20Pay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/modules/Helper/LocalStorage.js | 179 ++++++++++++++++++ .../resources/js/Helper/GooglePayStorage.js | 31 +++ 2 files changed, 210 insertions(+) create mode 100644 modules/ppcp-button/resources/js/modules/Helper/LocalStorage.js create mode 100644 modules/ppcp-googlepay/resources/js/Helper/GooglePayStorage.js diff --git a/modules/ppcp-button/resources/js/modules/Helper/LocalStorage.js b/modules/ppcp-button/resources/js/modules/Helper/LocalStorage.js new file mode 100644 index 000000000..65494369e --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Helper/LocalStorage.js @@ -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 ); + } +} diff --git a/modules/ppcp-googlepay/resources/js/Helper/GooglePayStorage.js b/modules/ppcp-googlepay/resources/js/Helper/GooglePayStorage.js new file mode 100644 index 000000000..faa2520e5 --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/Helper/GooglePayStorage.js @@ -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; From 63b505590326c10174c0e0c54db12e82cb57b33d Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 20 Aug 2024 14:14:30 +0200 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=92=A1=20Document=20payment=20workf?= =?UTF-8?q?low?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnApproveHandler/onApproveForContinue.js | 16 ++++++++++++---- .../resources/js/GooglepayButton.js | 9 +++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js index c60c163fd..0d699c170 100644 --- a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js +++ b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js @@ -18,17 +18,25 @@ const onApprove = ( context, errorHandler ) => { .then( ( res ) => { return res.json(); } ) - .then( ( data ) => { - if ( ! data.success ) { + .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; } ); diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index d8a910f34..a68e22f4a 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -587,6 +587,14 @@ class GooglepayButton extends PaymentButton { 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; @@ -625,6 +633,7 @@ class GooglepayButton extends PaymentButton { return; } + // This must be the last step in the process, as it initiates a redirect. const success = await approveOrderServerSide( id ); if ( success ) { From a51748f805416b51828c38f6600b38401d75dd7e Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 20 Aug 2024 14:18:30 +0200 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=92=A1=20Improve=20comments=20in=20?= =?UTF-8?q?PayerData=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/js/modules/Helper/PayerData.js | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index 1f97309c1..59af8dc85 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -10,30 +10,30 @@ * 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. + * @property {undefined|string} country_code - Country code (2-letter). + * @property {undefined|string} address_line_1 - Address details, line 1 (street, house number). + * @property {undefined|string} address_line_2 - Address details, line 2. + * @property {undefined|string} admin_area_1 - State or region. + * @property {undefined|string} admin_area_2 - State or region. + * @property {undefined|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. + * @property {undefined|string} phone_type - Type, usually 'HOME' + * @property {undefined|{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. + * @property {undefined|string} email_address - Email address for billing communication. + * @property {undefined|PhoneDetails} phone - Phone number for billing communication. + * @property {undefined|NameDetails} name - Payer's name. + * @property {undefined|AddressDetails} address - Postal billing address. */ // Map checkout fields to PayerData object properties. @@ -55,7 +55,14 @@ const FIELD_MAP = { * * @return {?PayerDetails} Full billing details, or null on failure. */ -export const payerData = () => { +export function payerData() { + /** + * PayPalCommerceGateway.payer can be set from server-side or via JS: + * - Server-side: Set by PHP when a WC customer is known. + * - Dynamic JS: When a payment method provided billing data. + * + * @see {setPayerData} + */ const payer = window.PayPalCommerceGateway?.payer; if ( ! payer ) { return null; @@ -104,18 +111,20 @@ export const payerData = () => { } return data; -}; +} /** * Updates the DOM with specific payer details. * + * Used by payment method callbacks that provide dedicated billing details, like Google Pay. + * Note: This code only works on classic checkout + * * @param {PayerDetails} newData - New payer details. - * @param {boolean} [overwriteExisting=false] - If set to true, all provided values will replace existing details. If false, or omitted, only undefined fields are updated. + * @param {boolean} [overwriteExisting=false] - If set to true, all provided values will + * replace existing details. If false, or omitted, + * only undefined fields are updated. */ -export const setPayerData = ( newData, overwriteExisting = false ) => { - // TODO: Check if we can add some kind of "filter" to allow customization of the data. - // Or add JS flags like "onlyUpdateMissing". - +export function setPayerData( newData, overwriteExisting = false ) { const setValue = ( path, field, value ) => { if ( null === value || undefined === value || ! field ) { return; @@ -136,4 +145,4 @@ export const setPayerData = ( newData, overwriteExisting = false ) => { setValue( path, element, value ); } ); -}; +} From c48c94e09d7030d86a4dfc6b3afc1fad25cd6596 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 20 Aug 2024 14:19:41 +0200 Subject: [PATCH 08/17] =?UTF-8?q?=E2=9C=A8=20New=20CheckoutBootstrap=20for?= =?UTF-8?q?=20GooglePay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This new module uses previously stored payer details to populate the checkout form on the classic checkout page. --- .../js/ContextBootstrap/CheckoutBootstrap.js | 64 +++++++++++++++++++ .../resources/js/GooglepayButton.js | 43 +++++++------ modules/ppcp-googlepay/resources/js/boot.js | 18 ++++-- 3 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js diff --git a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js new file mode 100644 index 000000000..b5e0c1a48 --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js @@ -0,0 +1,64 @@ +import { GooglePayStorage } from '../Helper/GooglePayStorage'; +import { setPayerData } from '../../../../ppcp-button/resources/js/modules/Helper/PayerData'; + +const CHECKOUT_FORM_SELECTOR = 'form.woocommerce-checkout'; + +export class CheckoutBootstrap { + /** + * @type {GooglePayStorage} + */ + #storage; + + /** + * @type {null|HTMLFormElement} + */ + #checkoutForm = null; + + constructor( storage ) { + this.#storage = storage; + + this.onFormSubmit = this.onFormSubmit.bind( this ); + } + + /** + * Returns the WooCommerce checkout form element. + * + * @return {HTMLFormElement|null} The form, or null if not a checkout page. + */ + get checkoutForm() { + if ( null === this.#checkoutForm ) { + this.#checkoutForm = document.querySelector( + CHECKOUT_FORM_SELECTOR + ); + } + + return this.#checkoutForm; + } + + /** + * Indicates, if the current page contains a checkout form. + * + * @return {boolean} True, if a checkout form is present. + */ + get isPageWithCheckoutForm() { + return this.checkoutForm instanceof HTMLElement; + } + + init() { + if ( ! this.isPageWithCheckoutForm ) { + return; + } + + const billingData = this.#storage.getPayer(); + + if ( billingData ) { + setPayerData( billingData ); + + this.checkoutForm.addEventListener( 'submit', this.onFormSubmit ); + } + } + + onFormSubmit() { + this.#storage.clearPayer(); + } +} diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index a68e22f4a..26acd0139 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -7,6 +7,7 @@ import widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/Wi 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. @@ -74,6 +75,26 @@ import { setPayerData } from '../../../ppcp-button/resources/js/modules/Helper/P * 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 @@ -643,30 +664,16 @@ class GooglepayButton extends PaymentButton { } }; - const propagatePayerDataToForm = () => { - const raw = paymentData?.paymentMethodData?.info?.billingAddress; - const payer = { - email_address: paymentData?.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, - }, - }; + const addBillingDataToSession = () => { + const payer = payerDataFromPaymentResponse( paymentData ); + moduleStorage.setPayer( payer ); setPayerData( payer ); }; return new Promise( async ( resolve ) => { try { - propagatePayerDataToForm(); + addBillingDataToSession(); await processPaymentPromise( resolve ); } catch ( err ) { resolve( paymentError( err.message ) ); diff --git a/modules/ppcp-googlepay/resources/js/boot.js b/modules/ppcp-googlepay/resources/js/boot.js index 99dd414f5..3071998a9 100644 --- a/modules/ppcp-googlepay/resources/js/boot.js +++ b/modules/ppcp-googlepay/resources/js/boot.js @@ -2,13 +2,23 @@ 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 } ) { + const context = ppcpConfig.context; -( function ( { buttonConfig, ppcpConfig, jQuery } ) { let manager; const bootstrap = function () { manager = new GooglepayManager( buttonConfig, ppcpConfig ); manager.init(); + + if ( 'continuation' === context || 'checkout' === context ) { + const checkoutBootstap = new CheckoutBootstrap( moduleStorage ); + + checkoutBootstap.init(); + } }; setupButtonEvents( function () { @@ -18,10 +28,7 @@ import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Hel } ); document.addEventListener( 'DOMContentLoaded', () => { - if ( - typeof buttonConfig === 'undefined' || - typeof ppcpConfig === 'undefined' - ) { + if ( ! buttonConfig || ! ppcpConfig ) { // No PayPal buttons present on this page. return; } @@ -52,5 +59,4 @@ import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Hel } )( { buttonConfig: window.wc_ppcp_googlepay, ppcpConfig: window.PayPalCommerceGateway, - jQuery: window.jQuery, } ); From 97379628d441602c3ca3425b7f6df253765b09f8 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 20 Aug 2024 15:37:26 +0200 Subject: [PATCH 09/17] =?UTF-8?q?=E2=9C=A8=20Send=20payer=20details=20with?= =?UTF-8?q?=20order=20approval=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step to integrate payer details in the payment flow without final confirmation. --- .../resources/js/modules/Helper/PayerData.js | 24 +++++++++++++++++++ .../OnApproveHandler/onApproveForContinue.js | 23 +++++++++++------- .../resources/js/GooglepayButton.js | 6 ++--- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index 59af8dc85..358b1587f 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -145,4 +145,28 @@ export function setPayerData( newData, overwriteExisting = false ) { setValue( path, element, value ); } ); + + /* + * Persist the payer details to the global JS object, to make it available in other modules + * via tha `payerData()` accessor. + */ + window.PayPalCommerceGateway.payer = + window.PayPalCommerceGateway.payer || {}; + const currentPayerData = payerData(); + + if ( currentPayerData ) { + Object.entries( newData ).forEach( ( [ key, value ] ) => { + if ( + overwriteExisting || + null !== currentPayerData[ key ] || + undefined !== currentPayerData[ key ] + ) { + currentPayerData[ key ] = value; + } + } ); + + window.PayPalCommerceGateway.payer = currentPayerData; + } else { + window.PayPalCommerceGateway.payer = newData; + } } diff --git a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js index 0d699c170..d492802f1 100644 --- a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js +++ b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js @@ -1,19 +1,26 @@ 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(); diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index 26acd0139..e9ca1bcc8 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -583,6 +583,8 @@ class GooglepayButton extends PaymentButton { async processPayment( paymentData ) { this.logGroup( 'processPayment' ); + const payer = payerDataFromPaymentResponse( paymentData ); + const paymentError = ( reason ) => { this.error( reason ); @@ -622,7 +624,7 @@ class GooglepayButton extends PaymentButton { this.log( 'approveOrder', orderID ); await this.contextHandler.approveOrder( - { orderID }, + { orderID, payer }, { restart: () => new Promise( ( resolve ) => { @@ -665,8 +667,6 @@ class GooglepayButton extends PaymentButton { }; const addBillingDataToSession = () => { - const payer = payerDataFromPaymentResponse( paymentData ); - moduleStorage.setPayer( payer ); setPayerData( payer ); }; From 915754799371e7e919289e4a547be8196377df55 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Wed, 21 Aug 2024 13:51:40 +0200 Subject: [PATCH 10/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Improve=20PayerData?= =?UTF-8?q?=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/js/modules/Helper/PayerData.js | 212 ++++++++++-------- 1 file changed, 118 insertions(+), 94 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index 358b1587f..6b899bc9e 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -2,38 +2,38 @@ * Name details. * * @typedef {Object} NameDetails - * @property {?string} given_name - First name, e.g. "John". - * @property {?string} surname - Last name, e.g. "Doe". + * @property {string} [given_name] - First name, e.g. "John". + * @property {string} [surname] - Last name, e.g. "Doe". */ /** * Postal address details. * * @typedef {Object} AddressDetails - * @property {undefined|string} country_code - Country code (2-letter). - * @property {undefined|string} address_line_1 - Address details, line 1 (street, house number). - * @property {undefined|string} address_line_2 - Address details, line 2. - * @property {undefined|string} admin_area_1 - State or region. - * @property {undefined|string} admin_area_2 - State or region. - * @property {undefined|string} postal_code - Zip code. + * @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 {undefined|string} phone_type - Type, usually 'HOME' - * @property {undefined|{national_number: string}} phone_number - Phone number details. + * @property {string} [phone_type] - Type, usually 'HOME' + * @property {{national_number: string}} [phone_number] - Phone number details. */ /** * Payer details. * * @typedef {Object} PayerDetails - * @property {undefined|string} email_address - Email address for billing communication. - * @property {undefined|PhoneDetails} phone - Phone number for billing communication. - * @property {undefined|NameDetails} name - Payer's name. - * @property {undefined|AddressDetails} address - Postal billing address. + * @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. @@ -50,123 +50,147 @@ const FIELD_MAP = { '#billing_phone': [ 'phone' ], }; -/** - * Returns billing details from the checkout form or global JS object. - * - * @return {?PayerDetails} Full billing details, or null on failure. - */ -export function payerData() { - /** - * PayPalCommerceGateway.payer can be set from server-side or via JS: - * - Server-side: Set by PHP when a WC customer is known. - * - Dynamic JS: When a payment method provided billing data. - * - * @see {setPayerData} - */ - const payer = window.PayPalCommerceGateway?.payer; - if ( ! payer ) { - return null; - } +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; - // Initialize data with existing payer values. - const data = { - email_address: payer.email_address, - phone: payer.phone, - name: { - surname: payer.name?.surname, - given_name: payer.name?.given_name, - }, - address: { - country_code: payer.address?.country_code, - address_line_1: payer.address?.address_line_1, - address_line_2: payer.address?.address_line_2, - admin_area_1: payer.address?.admin_area_1, - admin_area_2: payer.address?.admin_area_2, - postal_code: payer.address?.postal_code, - }, + 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; }; - // Update data with DOM values where they exist. + const data = {}; + Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => { const value = getElementValue( selector ); if ( value ) { - let current = data; - path.slice( 0, -1 ).forEach( ( key ) => { - current = current[ key ] = current[ key ] || {}; - } ); - current[ path[ path.length - 1 ] ] = value; + setNestedValue( data, path, value ); } } ); - // Handle phone separately due to its nested structure. - const phoneNumber = data.phone; - if ( phoneNumber && typeof phoneNumber === 'string' ) { + if ( data.phone && 'string' === typeof data.phone ) { data.phone = { phone_type: 'HOME', - phone_number: { national_number: phoneNumber }, + phone_number: { national_number: data.phone }, }; } return data; } -/** - * Updates the DOM with specific payer details. - * - * Used by payment method callbacks that provide dedicated billing details, like Google Pay. - * Note: This code only works on classic checkout - * - * @param {PayerDetails} newData - New payer details. - * @param {boolean} [overwriteExisting=false] - If set to true, all provided values will - * replace existing details. If false, or omitted, - * only undefined fields are updated. - */ -export function setPayerData( newData, overwriteExisting = false ) { +function setCheckoutBillingDetails( payer ) { const setValue = ( path, field, value ) => { if ( null === value || undefined === value || ! field ) { return; } - if ( path[ 0 ] === 'phone' && typeof value === 'object' ) { + if ( 'phone' === path[ 0 ] && 'object' === typeof value ) { value = value.phone_number?.national_number; } - if ( overwriteExisting || ! field.value ) { - field.value = value; - } + field.value = value; }; + const getNestedValue = ( obj, path ) => + path.reduce( ( current, key ) => current?.[ key ], obj ); + Object.entries( FIELD_MAP ).forEach( ( [ selector, path ] ) => { - const value = path.reduce( ( obj, key ) => obj?.[ key ], newData ); + const value = getNestedValue( payer, path ); const element = document.querySelector( selector ); setValue( path, element, value ); } ); +} - /* - * Persist the payer details to the global JS object, to make it available in other modules - * via tha `payerData()` accessor. - */ - window.PayPalCommerceGateway.payer = - window.PayPalCommerceGateway.payer || {}; - const currentPayerData = payerData(); +export function getWooCommerceCustomerDetails() { + // Populated on server-side with details about the current WooCommerce customer. + return window.PayPalCommerceGateway?.payer ?? null; +} - if ( currentPayerData ) { - Object.entries( newData ).forEach( ( [ key, value ] ) => { - if ( - overwriteExisting || - null !== currentPayerData[ key ] || - undefined !== currentPayerData[ key ] - ) { - currentPayerData[ key ] = value; - } - } ); +export function getSessionCustomerDetails() { + // Populated by JS via `setSessionCustomerDetails()` + return window.PayPalCommerceGateway?.sessionPayer ?? null; +} - window.PayPalCommerceGateway.payer = currentPayerData; - } else { - window.PayPalCommerceGateway.payer = newData; +/** + * 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 setSessionCustomerDetails( details ) { + if ( details && 'object' === typeof details ) { + window.PayPalCommerceGateway.sessionPayer = + normalizePayerDetails( details ); + } +} + +export function payerData() { + const payer = + getWooCommerceCustomerDetails() ?? getSessionCustomerDetails(); + + if ( ! payer ) { + return null; + } + + const formData = getCheckoutBillingDetails(); + + if ( formData ) { + return mergePayerDetails( payer, formData ); + } + + return normalizePayerDetails( payer ); +} + +export function setPayerData( newData, updateCheckout = false ) { + setSessionCustomerDetails( newData ); + + if ( updateCheckout ) { + setCheckoutBillingDetails( newData ); } } From eba92e6b81c48b1745d4ffbf68623ea878f976e1 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Wed, 21 Aug 2024 13:52:11 +0200 Subject: [PATCH 11/17] =?UTF-8?q?=E2=9C=A8=20Detect=20logged=20in=20custom?= =?UTF-8?q?er=20in=20checkout=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../js/ContextBootstrap/CheckoutBootstrap.js | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js index b5e0c1a48..d913b0b3f 100644 --- a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js +++ b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js @@ -1,5 +1,8 @@ import { GooglePayStorage } from '../Helper/GooglePayStorage'; -import { setPayerData } from '../../../../ppcp-button/resources/js/modules/Helper/PayerData'; +import { + getWooCommerceCustomerDetails, + setPayerData, +} from '../../../../ppcp-button/resources/js/modules/Helper/PayerData'; const CHECKOUT_FORM_SELECTOR = 'form.woocommerce-checkout'; @@ -49,16 +52,29 @@ export class CheckoutBootstrap { return; } + this.#populateCheckoutFields(); + } + + #populateCheckoutFields() { + const loggedInData = getWooCommerceCustomerDetails(); + + // If customer is logged in, we use the details from the customer profile. + if ( loggedInData ) { + return; + } + const billingData = this.#storage.getPayer(); if ( billingData ) { setPayerData( billingData ); - this.checkoutForm.addEventListener( 'submit', this.onFormSubmit ); + this.checkoutForm.addEventListener( 'submit', () => + this.#onFormSubmit() + ); } } - onFormSubmit() { + #onFormSubmit() { this.#storage.clearPayer(); } } From 734951adcb2d5250d5c768d29d6eb34936193350 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Wed, 21 Aug 2024 14:21:45 +0200 Subject: [PATCH 12/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Minor=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/js/modules/Helper/PayerData.js | 28 +++++++++---------- .../js/ContextBootstrap/CheckoutBootstrap.js | 17 ++++++----- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index 6b899bc9e..df13ef92f 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -149,12 +149,12 @@ function setCheckoutBillingDetails( payer ) { export function getWooCommerceCustomerDetails() { // Populated on server-side with details about the current WooCommerce customer. - return window.PayPalCommerceGateway?.payer ?? null; + return window.PayPalCommerceGateway?.payer; } -export function getSessionCustomerDetails() { - // Populated by JS via `setSessionCustomerDetails()` - return window.PayPalCommerceGateway?.sessionPayer ?? null; +export function getSessionBillingDetails() { + // Populated by JS via `setSessionBillingDetails()` + return window.PayPalCommerceGateway?.tempPayer; } /** @@ -163,16 +163,16 @@ export function getSessionCustomerDetails() { * * @param {unknown} details - New payer details */ -export function setSessionCustomerDetails( details ) { - if ( details && 'object' === typeof details ) { - window.PayPalCommerceGateway.sessionPayer = - normalizePayerDetails( details ); +export function setSessionBillingDetails( details ) { + if ( ! details || 'object' !== typeof details ) { + return; } + + window.PayPalCommerceGateway.tempPayer = normalizePayerDetails( details ); } export function payerData() { - const payer = - getWooCommerceCustomerDetails() ?? getSessionCustomerDetails(); + const payer = getWooCommerceCustomerDetails() ?? getSessionBillingDetails(); if ( ! payer ) { return null; @@ -187,10 +187,10 @@ export function payerData() { return normalizePayerDetails( payer ); } -export function setPayerData( newData, updateCheckout = false ) { - setSessionCustomerDetails( newData ); +export function setPayerData( payerDetails, updateCheckoutForm = false ) { + setSessionBillingDetails( payerDetails ); - if ( updateCheckout ) { - setCheckoutBillingDetails( newData ); + if ( updateCheckoutForm ) { + setCheckoutBillingDetails( payerDetails ); } } diff --git a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js index d913b0b3f..9e1c30e9f 100644 --- a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js +++ b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js @@ -19,8 +19,6 @@ export class CheckoutBootstrap { constructor( storage ) { this.#storage = storage; - - this.onFormSubmit = this.onFormSubmit.bind( this ); } /** @@ -44,7 +42,7 @@ export class CheckoutBootstrap { * @return {boolean} True, if a checkout form is present. */ get isPageWithCheckoutForm() { - return this.checkoutForm instanceof HTMLElement; + return null !== this.checkoutForm; } init() { @@ -65,13 +63,14 @@ export class CheckoutBootstrap { const billingData = this.#storage.getPayer(); - if ( billingData ) { - setPayerData( billingData ); - - this.checkoutForm.addEventListener( 'submit', () => - this.#onFormSubmit() - ); + if ( ! billingData ) { + return; } + + setPayerData( billingData, true ); + this.checkoutForm.addEventListener( 'submit', () => + this.#onFormSubmit() + ); } #onFormSubmit() { From 3e0a44ca1f57b7a954ed1702cfc8a03bd0a0c411 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Mon, 26 Aug 2024 18:22:42 +0200 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=9A=A7=20Enqueue=20missing=20script?= =?UTF-8?q?=20for=20billing=20data=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When PayLater is disabled and we're in "continuation" context, then the new billing data logic is not working: The relevant JS script is not enqueued. --- modules/ppcp-googlepay/src/GooglepayModule.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/ppcp-googlepay/src/GooglepayModule.php b/modules/ppcp-googlepay/src/GooglepayModule.php index b7feedc07..9079a4b9a 100644 --- a/modules/ppcp-googlepay/src/GooglepayModule.php +++ b/modules/ppcp-googlepay/src/GooglepayModule.php @@ -93,6 +93,12 @@ class GooglepayModule implements ModuleInterface { static function () use ( $c, $button ) { $smart_button = $c->get( 'button.smart-button' ); assert( $smart_button instanceof SmartButtonInterface ); + + /* + * TODO: When PayLater is disabled and we're in "continuation" context, then no JS is enqueued. + * Find a solution to enqueue the CheckoutBootstrap module in that situation. + */ + if ( $smart_button->should_load_ppcp_script() ) { $button->enqueue(); return; From 15a09d9722f8f9d1e1a862d110bed61d768373fb Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 27 Aug 2024 12:32:00 +0200 Subject: [PATCH 14/17] =?UTF-8?q?=E2=9C=A8=20Decouple=20PayerData=20from?= =?UTF-8?q?=20global=20PPCP=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ppcp-button/resources/js/modules/Helper/PayerData.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index df13ef92f..5695facb0 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -149,12 +149,12 @@ function setCheckoutBillingDetails( payer ) { export function getWooCommerceCustomerDetails() { // Populated on server-side with details about the current WooCommerce customer. - return window.PayPalCommerceGateway?.payer; + return window?.PayPalCommerceGateway?.payer; } export function getSessionBillingDetails() { // Populated by JS via `setSessionBillingDetails()` - return window.PayPalCommerceGateway?.tempPayer; + return window._PpcpPayerSessionDetails; } /** @@ -168,7 +168,7 @@ export function setSessionBillingDetails( details ) { return; } - window.PayPalCommerceGateway.tempPayer = normalizePayerDetails( details ); + window._PpcpPayerSessionDetails = normalizePayerDetails( details ); } export function payerData() { From 07c73985e3ece321284e271138822c7b0c9fdb40 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 27 Aug 2024 12:32:53 +0200 Subject: [PATCH 15/17] =?UTF-8?q?=E2=9C=A8=20Always=20load=20GooglePay=20s?= =?UTF-8?q?cripts=20on=20checkout=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/ppcp-googlepay/src/GooglepayModule.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/modules/ppcp-googlepay/src/GooglepayModule.php b/modules/ppcp-googlepay/src/GooglepayModule.php index 9079a4b9a..114fe7f3d 100644 --- a/modules/ppcp-googlepay/src/GooglepayModule.php +++ b/modules/ppcp-googlepay/src/GooglepayModule.php @@ -94,16 +94,20 @@ class GooglepayModule implements ModuleInterface { $smart_button = $c->get( 'button.smart-button' ); assert( $smart_button instanceof SmartButtonInterface ); - /* - * TODO: When PayLater is disabled and we're in "continuation" context, then no JS is enqueued. - * Find a solution to enqueue the CheckoutBootstrap module in that situation. - */ - 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. From 5b054581036fab0486bf08ac9cf00f84d23671ae Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 27 Aug 2024 12:34:50 +0200 Subject: [PATCH 16/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Decouple=20init=20lo?= =?UTF-8?q?gic=20from=20global=20PPCP=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow Google Pay logic to initialize on pages that do not provide a global PayPalCommerceGateway object. Required to use CheckoutBootstrap to popuplate billing fields in continuation mode. --- .../js/ContextBootstrap/CheckoutBootstrap.js | 61 +++++++++++++------ modules/ppcp-googlepay/resources/js/boot.js | 56 ++++++++++++----- 2 files changed, 83 insertions(+), 34 deletions(-) diff --git a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js index 9e1c30e9f..1e1933a10 100644 --- a/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js +++ b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js @@ -13,12 +13,34 @@ export class CheckoutBootstrap { #storage; /** - * @type {null|HTMLFormElement} + * @type {HTMLFormElement|null} */ - #checkoutForm = 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 ); } /** @@ -27,37 +49,32 @@ export class CheckoutBootstrap { * @return {HTMLFormElement|null} The form, or null if not a checkout page. */ get checkoutForm() { - if ( null === this.#checkoutForm ) { - this.#checkoutForm = document.querySelector( - CHECKOUT_FORM_SELECTOR - ); - } - return this.#checkoutForm; } /** - * Indicates, if the current page contains a checkout form. + * Initializes the checkout process. * - * @return {boolean} True, if a checkout form is present. + * @throws {Error} If called on a page without a checkout form. */ - get isPageWithCheckoutForm() { - return null !== this.checkoutForm; - } - init() { - if ( ! this.isPageWithCheckoutForm ) { - return; + 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 customer is logged in, we use the details from the customer profile. if ( loggedInData ) { + // If customer is logged in, we use the details from the customer profile. return; } @@ -68,11 +85,17 @@ export class CheckoutBootstrap { } setPayerData( billingData, true ); - this.checkoutForm.addEventListener( 'submit', () => - this.#onFormSubmit() + 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(); } diff --git a/modules/ppcp-googlepay/resources/js/boot.js b/modules/ppcp-googlepay/resources/js/boot.js index 3071998a9..666286ed4 100644 --- a/modules/ppcp-googlepay/resources/js/boot.js +++ b/modules/ppcp-googlepay/resources/js/boot.js @@ -1,3 +1,12 @@ +/** + * 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'; @@ -5,31 +14,48 @@ import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Hel import { CheckoutBootstrap } from './ContextBootstrap/CheckoutBootstrap'; import moduleStorage from './Helper/GooglePayStorage'; -( function ( { buttonConfig, ppcpConfig } ) { +( function ( { buttonConfig, ppcpConfig = {} } ) { const context = ppcpConfig.context; - let manager; + function bootstrapPayButton() { + if ( ! buttonConfig || ! ppcpConfig ) { + return; + } - const bootstrap = function () { - manager = new GooglepayManager( buttonConfig, ppcpConfig ); + const manager = new GooglepayManager( buttonConfig, ppcpConfig ); manager.init(); - if ( 'continuation' === context || 'checkout' === context ) { - const checkoutBootstap = new CheckoutBootstrap( moduleStorage ); - - checkoutBootstap.init(); - } - }; - - setupButtonEvents( function () { - if ( manager ) { + 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 ( ! buttonConfig || ! ppcpConfig ) { - // No PayPal buttons present on this page. + /* + * No PayPal buttons present on this page, but maybe a bootstrap module needs to be + * initialized. Run bootstrap without trying to load an SDK or payment configuration. + */ + bootstrap(); + return; } From 813f24da1cc717f09bf4a7437bb0c4b42d46a1e8 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 27 Aug 2024 12:46:15 +0200 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=92=A1=20Update=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/ppcp-googlepay/resources/js/boot.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/ppcp-googlepay/resources/js/boot.js b/modules/ppcp-googlepay/resources/js/boot.js index 666286ed4..fb9e8e313 100644 --- a/modules/ppcp-googlepay/resources/js/boot.js +++ b/modules/ppcp-googlepay/resources/js/boot.js @@ -52,7 +52,8 @@ import moduleStorage from './Helper/GooglePayStorage'; if ( ! buttonConfig || ! ppcpConfig ) { /* * No PayPal buttons present on this page, but maybe a bootstrap module needs to be - * initialized. Run bootstrap without trying to load an SDK or payment configuration. + * initialized. Skip loading the SDK or gateway configuration, and directly initialize + * the module. */ bootstrap();