diff --git a/.distignore b/.distignore index 836792c75..614705af1 100644 --- a/.distignore +++ b/.distignore @@ -14,7 +14,8 @@ tests .phpunit.result.cache babel.config.json node_modules -modules/*/resources +modules/*/resources/css +modules/*/resources/js/**/*.js *.lock webpack.config.js wp-cli.yml diff --git a/modules/ppcp-api-client/services.php b/modules/ppcp-api-client/services.php index 6049b050b..8b2ff9af0 100644 --- a/modules/ppcp-api-client/services.php +++ b/modules/ppcp-api-client/services.php @@ -1642,6 +1642,21 @@ return array( 'SE', ); }, + + 'api.paylater-countries' => static function ( ContainerInterface $container ) : array { + return apply_filters( + 'woocommerce_paypal_payments_supported_paylater_countries', + array( + 'US', + 'DE', + 'GB', + 'FR', + 'AU', + 'IT', + 'ES', + ) + ); + }, 'api.order-helper' => static function( ContainerInterface $container ): OrderHelper { return new OrderHelper(); }, diff --git a/modules/ppcp-applepay/assets/images/applepay.png b/modules/ppcp-applepay/assets/images/applepay.png deleted file mode 100644 index d46807d77..000000000 Binary files a/modules/ppcp-applepay/assets/images/applepay.png and /dev/null differ diff --git a/modules/ppcp-applepay/assets/images/applepay.svg b/modules/ppcp-applepay/assets/images/applepay.svg new file mode 100644 index 000000000..42e09634d --- /dev/null +++ b/modules/ppcp-applepay/assets/images/applepay.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/ppcp-applepay/extensions.php b/modules/ppcp-applepay/extensions.php index 977b1f470..5eb6e47d6 100644 --- a/modules/ppcp-applepay/extensions.php +++ b/modules/ppcp-applepay/extensions.php @@ -101,7 +101,7 @@ return array( 'applepay_button_enabled' => array( 'title' => __( 'Apple Pay Button', 'woocommerce-paypal-payments' ), 'title_html' => sprintf( - '%s', + '%s', $module_url, __( 'Apple Pay', 'woocommerce-paypal-payments' ) ), @@ -155,7 +155,7 @@ return array( 'applepay_button_enabled' => array( 'title' => __( 'Apple Pay Button', 'woocommerce-paypal-payments' ), 'title_html' => sprintf( - '%s', + '%s', $module_url, __( 'Apple Pay', 'woocommerce-paypal-payments' ) ), diff --git a/modules/ppcp-applepay/resources/css/styles.scss b/modules/ppcp-applepay/resources/css/styles.scss index 3402b1a20..d2a0bf675 100644 --- a/modules/ppcp-applepay/resources/css/styles.scss +++ b/modules/ppcp-applepay/resources/css/styles.scss @@ -23,6 +23,11 @@ &.ppcp-button-minicart { --apple-pay-button-display: block; } + + &.ppcp-preview-button.ppcp-button-dummy { + /* URL must specify the correct module-folder! */ + --apm-button-dummy-background: url(../../../ppcp-applepay/assets/images/applepay.png); + } } .wp-block-woocommerce-checkout, .wp-block-woocommerce-cart { diff --git a/modules/ppcp-applepay/resources/js/ApplepayButton.js b/modules/ppcp-applepay/resources/js/ApplepayButton.js index 1c77a5c45..0f217f606 100644 --- a/modules/ppcp-applepay/resources/js/ApplepayButton.js +++ b/modules/ppcp-applepay/resources/js/ApplepayButton.js @@ -57,6 +57,8 @@ const CONTEXT = { * On a single page, multiple Apple Pay buttons can be displayed, which also means multiple * ApplePayButton instances exist. A typical case is on the product page, where one Apple Pay button * is located inside the minicart-popup, and another pay-now button is in the product context. + * + * TODO - extend from PaymentButton (same as we do in GooglepayButton.js) */ class ApplePayButton { /** @@ -490,6 +492,7 @@ class ApplePayButton { const ppcpStyle = this.ppcpStyle; wrapper.innerHTML = ``; + wrapper.classList.remove( 'ppcp-button-rect', 'ppcp-button-pill' ); wrapper.classList.add( `ppcp-button-${ ppcpStyle.shape }`, 'ppcp-button-apm', diff --git a/modules/ppcp-applepay/resources/js/Preview/ApplePayPreviewButton.js b/modules/ppcp-applepay/resources/js/Preview/ApplePayPreviewButton.js new file mode 100644 index 000000000..2ad3e23f5 --- /dev/null +++ b/modules/ppcp-applepay/resources/js/Preview/ApplePayPreviewButton.js @@ -0,0 +1,54 @@ +import ApplepayButton from '../ApplepayButton'; +import PreviewButton from '../../../../ppcp-button/resources/js/modules/Preview/PreviewButton'; + +/** + * A single Apple Pay preview button instance. + */ +export default class ApplePayPreviewButton extends PreviewButton { + constructor( args ) { + super( args ); + + this.selector = `${ args.selector }ApplePay`; + this.defaultAttributes = { + button: { + type: 'pay', + color: 'black', + lang: 'en', + }, + }; + } + + createButton( buttonConfig ) { + const button = new ApplepayButton( + 'preview', + null, + buttonConfig, + this.ppcpConfig + ); + + button.init( this.apiConfig ); + } + + /** + * Merge form details into the config object for preview. + * Mutates the previewConfig object; no return value. + * @param buttonConfig + * @param ppcpConfig + */ + dynamicPreviewConfig( buttonConfig, ppcpConfig ) { + // The Apple Pay button expects the "wrapper" to be an ID without `#` prefix! + buttonConfig.button.wrapper = buttonConfig.button.wrapper.replace( + /^#/, + '' + ); + + // Merge the current form-values into the preview-button configuration. + if ( ppcpConfig.button ) { + buttonConfig.button.type = ppcpConfig.button.style.type; + buttonConfig.button.color = ppcpConfig.button.style.color; + buttonConfig.button.lang = + ppcpConfig.button.style?.lang || + ppcpConfig.button.style.language; + } + } +} diff --git a/modules/ppcp-applepay/resources/js/Preview/ApplePayPreviewButtonManager.js b/modules/ppcp-applepay/resources/js/Preview/ApplePayPreviewButtonManager.js new file mode 100644 index 000000000..be15166e6 --- /dev/null +++ b/modules/ppcp-applepay/resources/js/Preview/ApplePayPreviewButtonManager.js @@ -0,0 +1,50 @@ +import PreviewButtonManager from '../../../../ppcp-button/resources/js/modules/Preview/PreviewButtonManager'; +import ApplePayPreviewButton from './ApplePayPreviewButton'; + +/** + * Manages all Apple Pay preview buttons on this page. + */ +export default class ApplePayPreviewButtonManager extends PreviewButtonManager { + constructor() { + const args = { + methodName: 'ApplePay', + buttonConfig: window.wc_ppcp_applepay_admin, + }; + + super( args ); + } + + /** + * Responsible for fetching and returning the PayPal configuration object for this payment + * method. + * + * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. + * @return {Promise<{}>} + */ + async fetchConfig( payPal ) { + const apiMethod = payPal?.Applepay()?.config; + + if ( ! apiMethod ) { + this.error( + 'configuration object cannot be retrieved from PayPal' + ); + return {}; + } + + return await apiMethod(); + } + + /** + * This method is responsible for creating a new PreviewButton instance and returning it. + * + * @param {string} wrapperId - CSS ID of the wrapper element. + * @return {ApplePayPreviewButton} + */ + createButtonInstance( wrapperId ) { + return new ApplePayPreviewButton( { + selector: wrapperId, + apiConfig: this.apiConfig, + methodName: this.methodName, + } ); + } +} diff --git a/modules/ppcp-applepay/resources/js/boot-admin.js b/modules/ppcp-applepay/resources/js/boot-admin.js index 81d8c0b7b..e92c03cdf 100644 --- a/modules/ppcp-applepay/resources/js/boot-admin.js +++ b/modules/ppcp-applepay/resources/js/boot-admin.js @@ -1,6 +1,4 @@ -import ApplePayButton from './ApplepayButton'; -import PreviewButton from '../../../ppcp-button/resources/js/modules/Renderer/PreviewButton'; -import PreviewButtonManager from '../../../ppcp-button/resources/js/modules/Renderer/PreviewButtonManager'; +import ApplePayPreviewButtonManager from './Preview/ApplePayPreviewButtonManager'; /** * Accessor that creates and returns a single PreviewButtonManager instance. @@ -14,111 +12,5 @@ const buttonManager = () => { return ApplePayPreviewButtonManager.instance; }; -/** - * Manages all Apple Pay preview buttons on this page. - */ -class ApplePayPreviewButtonManager extends PreviewButtonManager { - constructor() { - const args = { - methodName: 'ApplePay', - buttonConfig: window.wc_ppcp_applepay_admin, - }; - - super( args ); - } - - /** - * Responsible for fetching and returning the PayPal configuration object for this payment - * method. - * - * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. - * @return {Promise<{}>} - */ - async fetchConfig( payPal ) { - const apiMethod = payPal?.Applepay()?.config; - - if ( ! apiMethod ) { - this.error( - 'configuration object cannot be retrieved from PayPal' - ); - return {}; - } - - return await apiMethod(); - } - - /** - * This method is responsible for creating a new PreviewButton instance and returning it. - * - * @param {string} wrapperId - CSS ID of the wrapper element. - * @return {ApplePayPreviewButton} - */ - createButtonInstance( wrapperId ) { - return new ApplePayPreviewButton( { - selector: wrapperId, - apiConfig: this.apiConfig, - } ); - } -} - -/** - * A single Apple Pay preview button instance. - */ -class ApplePayPreviewButton extends PreviewButton { - constructor( args ) { - super( args ); - - this.selector = `${ args.selector }ApplePay`; - this.defaultAttributes = { - button: { - type: 'pay', - color: 'black', - lang: 'en', - }, - }; - } - - createNewWrapper() { - const element = super.createNewWrapper(); - element.addClass( 'ppcp-button-applepay' ); - - return element; - } - - createButton( buttonConfig ) { - const button = new ApplePayButton( - 'preview', - null, - buttonConfig, - this.ppcpConfig - ); - - button.init( this.apiConfig ); - } - - /** - * Merge form details into the config object for preview. - * Mutates the previewConfig object; no return value. - * @param buttonConfig - * @param ppcpConfig - */ - dynamicPreviewConfig( buttonConfig, ppcpConfig ) { - // The Apple Pay button expects the "wrapper" to be an ID without `#` prefix! - buttonConfig.button.wrapper = buttonConfig.button.wrapper.replace( - /^#/, - '' - ); - - // Merge the current form-values into the preview-button configuration. - if ( ppcpConfig.button ) { - buttonConfig.button.type = ppcpConfig.button.style.type; - buttonConfig.button.color = ppcpConfig.button.style.color; - buttonConfig.button.lang = - ppcpConfig.button.style?.lang || - ppcpConfig.button.style.language; - } - } -} - // Initialize the preview button manager. buttonManager(); diff --git a/modules/ppcp-applepay/src/ApplePayGateway.php b/modules/ppcp-applepay/src/ApplePayGateway.php index 7c87258f9..3ee57ee96 100644 --- a/modules/ppcp-applepay/src/ApplePayGateway.php +++ b/modules/ppcp-applepay/src/ApplePayGateway.php @@ -112,7 +112,7 @@ class ApplePayGateway extends WC_Payment_Gateway { $this->description = $this->get_option( 'description', '' ); $this->module_url = $module_url; - $this->icon = esc_url( $this->module_url ) . 'assets/images/applepay.png'; + $this->icon = esc_url( $this->module_url ) . 'assets/images/applepay.svg'; $this->init_form_fields(); $this->init_settings(); diff --git a/modules/ppcp-axo/extensions.php b/modules/ppcp-axo/extensions.php index fe67bb3d5..9d26cffd5 100644 --- a/modules/ppcp-axo/extensions.php +++ b/modules/ppcp-axo/extensions.php @@ -120,7 +120,6 @@ return array( '', array( $container->get( 'axo.settings-conflict-notice' ), - $container->get( 'axo.shipping-config-notice' ), $container->get( 'axo.checkout-config-notice' ), $container->get( 'axo.incompatible-plugins-notice' ), ) diff --git a/modules/ppcp-axo/resources/js/AxoManager.js b/modules/ppcp-axo/resources/js/AxoManager.js index 34b8d24c5..3144d28a1 100644 --- a/modules/ppcp-axo/resources/js/AxoManager.js +++ b/modules/ppcp-axo/resources/js/AxoManager.js @@ -11,17 +11,54 @@ import { } from '../../../ppcp-button/resources/js/modules/Helper/ButtonDisabler'; import { getCurrentPaymentMethod } from '../../../ppcp-button/resources/js/modules/Helper/CheckoutMethodState'; +/** + * Internal customer details. + * + * @typedef {Object} CustomerDetails + * @property {null|string} email - Customer email. + * @property {null|string} phone - Fastlane phone number. + * @property {null|Object} billing - Billing details object. + * @property {null|Object} shipping - Shipping details object. + * @property {null|Object} card - Payment details object. + */ + class AxoManager { + axoConfig = null; + ppcpConfig = null; + $ = null; + + fastlane = null; + /** + * @type {FastlaneCardComponent} + */ + cardComponent = null; + + initialized = false; + hideGatewaySelection = false; + phoneNumber = null; + + /** + * @type {CustomerDetails} + */ + data = {}; + status = {}; + styles = {}; + locale = 'en_us'; + + el = null; + emailInput = null; + phoneInput = null; + shippingView = null; + billingView = null; + cardView = null; + constructor( axoConfig, ppcpConfig ) { this.axoConfig = axoConfig; this.ppcpConfig = ppcpConfig; - this.initialized = false; this.fastlane = new Fastlane(); this.$ = jQuery; - this.hideGatewaySelection = false; - this.status = { active: false, validEmail: false, @@ -30,13 +67,9 @@ class AxoManager { hasCard: false, }; - this.data = { - email: null, - billing: null, - shipping: null, - card: null, - }; + this.clearData(); + // TODO - Do we need a public `states` property for this? this.states = this.axoConfig.woocommerce.states; this.el = new DomElementCollection(); @@ -51,8 +84,6 @@ class AxoManager { }, }; - this.locale = 'en_us'; - this.registerEventHandlers(); this.shippingView = new ShippingView( @@ -101,9 +132,22 @@ class AxoManager { } } + this.onChangePhone = this.onChangePhone.bind( this ); + this.initPhoneSyncWooToFastlane(); + this.triggerGatewayChange(); } + /** + * Checks if the current flow is the "Ryan flow": Ryan is a known customer who created a + * Fastlane profile before. Ryan can leverage all benefits of the accelerated 1-click checkout. + * + * @return {boolean} True means, Fastlane could link the customer's email to an existing account. + */ + get isRyanFlow() { + return !! this.data.card; + } + registerEventHandlers() { this.$( document ).on( 'change', @@ -458,6 +502,7 @@ class AxoManager { this.initPlacements(); this.initFastlane(); this.setStatus( 'active', true ); + this.readPhoneFromWoo(); log( `Attempt on activation - emailInput: ${ this.emailInput.value }` ); log( @@ -468,6 +513,8 @@ class AxoManager { this.lastEmailCheckedIdentity !== this.emailInput.value ) { this.onChangeEmail(); + } else { + this.refreshFastlanePrefills(); } } @@ -690,6 +737,51 @@ class AxoManager { } } + /** + * Locates the WooCommerce checkout "billing phone" field and adds event listeners to it. + */ + initPhoneSyncWooToFastlane() { + this.phoneInput = document.querySelector( '#billing_phone' ); + this.phoneInput?.addEventListener( 'change', this.onChangePhone ); + } + + /** + * Strips the country prefix and non-numeric characters from the phone number. If the remaining + * phone number is valid, it's returned. Otherwise, the function returns null. + * + * @param {string} number - Phone number to sanitize. + * @return {string|null} A valid US phone number, or null if the number is invalid. + */ + sanitizePhoneNumber( number ) { + const localNumber = number.replace( /^\+1/, '' ); + const cleanNumber = localNumber.replace( /\D/g, '' ); + + // All valid US mobile numbers have exactly 10 digits. + return cleanNumber.length === 10 ? cleanNumber : null; + } + + /** + * Reads the phone number from the WooCommerce checkout form, sanitizes it, and (if valid) + * stores it in the internal customer details object. + * + * @return {boolean} True, if the internal phone number was updated. + */ + readPhoneFromWoo() { + if ( ! this.phoneInput ) { + return false; + } + + const phoneNumber = this.phoneInput.value; + const validPhoneNumber = this.sanitizePhoneNumber( phoneNumber ); + + if ( ! validPhoneNumber ) { + return false; + } + + this.data.phone = validPhoneNumber; + return true; + } + async onChangeEmail() { this.clearData(); @@ -733,6 +825,8 @@ class AxoManager { this.data.email = this.emailInput.value; this.billingView.setData( this.data ); + this.readPhoneFromWoo(); + if ( ! this.fastlane.identity ) { log( 'Not initialized.' ); return; @@ -757,6 +851,22 @@ class AxoManager { this.enableGatewaySelection(); } + /** + * Event handler that fires when the customer changed the phone number in the WooCommerce + * checkout form. If Fastlane is active, the component is refreshed. + * + * @return {Promise} + */ + async onChangePhone() { + const hasChanged = this.readPhoneFromWoo(); + + if ( hasChanged && this.status.active ) { + await this.refreshFastlanePrefills(); + } + + return Promise.resolve(); + } + async lookupCustomerByEmail() { const lookupResponse = await this.fastlane.identity.lookupCustomerByEmail( @@ -792,11 +902,7 @@ class AxoManager { if ( authResponse.profileData.card ) { this.setStatus( 'hasCard', true ); } else { - this.cardComponent = ( - await this.fastlane.FastlaneCardComponent( - this.cardComponentData() - ) - ).render( this.el.paymentContainer.selector + '-form' ); + await this.initializeFastlaneComponent(); } const cardBillingAddress = @@ -837,12 +943,7 @@ class AxoManager { this.setStatus( 'hasProfile', false ); await this.renderWatermark( true ); - - this.cardComponent = ( - await this.fastlane.FastlaneCardComponent( - this.cardComponentData() - ) - ).render( this.el.paymentContainer.selector + '-form' ); + await this.initializeFastlaneComponent(); } } else { // No profile found with this email address. @@ -853,12 +954,7 @@ class AxoManager { this.setStatus( 'hasProfile', false ); await this.renderWatermark( true ); - - this.cardComponent = ( - await this.fastlane.FastlaneCardComponent( - this.cardComponentData() - ) - ).render( this.el.paymentContainer.selector + '-form' ); + await this.initializeFastlaneComponent(); } } @@ -873,6 +969,7 @@ class AxoManager { clearData() { this.data = { email: null, + phone: null, billing: null, shipping: null, card: null, @@ -897,7 +994,7 @@ class AxoManager { onClickSubmitButton() { // TODO: validate data. - if ( this.data.card ) { + if ( this.isRyanFlow ) { // Ryan flow log( 'Starting Ryan flow.' ); @@ -935,7 +1032,7 @@ class AxoManager { } cardComponentData() { - return { + const config = { fields: { cardholderName: { enabled: this.axoConfig.name_on_card === '1', @@ -945,6 +1042,56 @@ class AxoManager { this.axoConfig.style_options ), }; + + // Ryan is a known customer, we do not need his phone number. + if ( this.data.phone && ! this.isRyanFlow ) { + config.fields.phoneNumber = { + prefill: this.data.phone, + }; + } + + return config; + } + + /** + * Initializes the Fastlane UI component, using configuration provided by the + * `cardComponentData()` method. If the UI component was already initialized, nothing happens. + * + * @return {Promise<*>} Resolves when the component was rendered. + */ + async initializeFastlaneComponent() { + if ( ! this.status.active || this.cardComponent ) { + return Promise.resolve(); + } + + const elem = this.el.paymentContainer.selector + '-form'; + const config = this.cardComponentData(); + + this.cardComponent = + await this.fastlane.FastlaneCardComponent( config ); + + return this.cardComponent.render( elem ); + } + + /** + * Updates the prefill-values in the UI component. This method only updates empty fields. + * + * @return {Promise<*>} Resolves when the component was refreshed. + */ + async refreshFastlanePrefills() { + if ( ! this.cardComponent ) { + return Promise.resolve(); + } + + const { fields } = this.cardComponentData(); + const prefills = Object.keys( fields ).reduce( ( result, key ) => { + if ( fields[ key ].hasOwnProperty( 'prefill' ) ) { + result[ key ] = fields[ key ].prefill; + } + return result; + }, {} ); + + return this.cardComponent.updatePrefills( prefills ); } tokenizeData() { @@ -1081,8 +1228,8 @@ class AxoManager { ensureBillingPhoneNumber( data ) { if ( data.billing_phone === '' ) { let phone = ''; - const cc = this.data?.shipping?.phoneNumber?.countryCode; - const number = this.data?.shipping?.phoneNumber?.nationalNumber; + const cc = this.data.shipping?.phoneNumber?.countryCode; + const number = this.data.shipping?.phoneNumber?.nationalNumber; if ( cc ) { phone = `+${ cc } `; diff --git a/modules/ppcp-axo/services.php b/modules/ppcp-axo/services.php index 5b468835f..0519e9209 100644 --- a/modules/ppcp-axo/services.php +++ b/modules/ppcp-axo/services.php @@ -21,14 +21,14 @@ use WooCommerce\PayPalCommerce\WcGateway\Settings\Settings; return array( // If AXO can be configured. - 'axo.eligible' => static function ( ContainerInterface $container ): bool { + 'axo.eligible' => static function ( ContainerInterface $container ): bool { $apm_applies = $container->get( 'axo.helpers.apm-applies' ); assert( $apm_applies instanceof ApmApplies ); return $apm_applies->for_country_currency(); }, - 'axo.helpers.apm-applies' => static function ( ContainerInterface $container ) : ApmApplies { + 'axo.helpers.apm-applies' => static function ( ContainerInterface $container ) : ApmApplies { return new ApmApplies( $container->get( 'axo.supported-country-currency-matrix' ), $container->get( 'api.shop.currency' ), @@ -36,16 +36,16 @@ return array( ); }, - 'axo.helpers.settings-notice-generator' => static function ( ContainerInterface $container ) : SettingsNoticeGenerator { - return new SettingsNoticeGenerator(); + 'axo.helpers.settings-notice-generator' => static function ( ContainerInterface $container ) : SettingsNoticeGenerator { + return new SettingsNoticeGenerator( $container->get( 'axo.fastlane-incompatible-plugin-names' ) ); }, // If AXO is configured and onboarded. - 'axo.available' => static function ( ContainerInterface $container ): bool { + 'axo.available' => static function ( ContainerInterface $container ): bool { return true; }, - 'axo.url' => static function ( ContainerInterface $container ): string { + 'axo.url' => static function ( ContainerInterface $container ): string { $path = realpath( __FILE__ ); if ( false === $path ) { return ''; @@ -56,7 +56,7 @@ return array( ); }, - 'axo.manager' => static function ( ContainerInterface $container ): AxoManager { + 'axo.manager' => static function ( ContainerInterface $container ): AxoManager { return new AxoManager( $container->get( 'axo.url' ), $container->get( 'ppcp.asset-version' ), @@ -70,7 +70,7 @@ return array( ); }, - 'axo.gateway' => static function ( ContainerInterface $container ): AxoGateway { + 'axo.gateway' => static function ( ContainerInterface $container ): AxoGateway { return new AxoGateway( $container->get( 'wcgateway.settings.render' ), $container->get( 'wcgateway.settings' ), @@ -87,7 +87,7 @@ return array( ); }, - 'axo.card_icons' => static function ( ContainerInterface $container ): array { + 'axo.card_icons' => static function ( ContainerInterface $container ): array { return array( array( 'title' => 'Visa', @@ -108,7 +108,7 @@ return array( ); }, - 'axo.card_icons.axo' => static function ( ContainerInterface $container ): array { + 'axo.card_icons.axo' => static function ( ContainerInterface $container ): array { return array( array( 'title' => 'Visa', @@ -144,7 +144,7 @@ return array( /** * The matrix which countries and currency combinations can be used for AXO. */ - 'axo.supported-country-currency-matrix' => static function ( ContainerInterface $container ) : array { + 'axo.supported-country-currency-matrix' => static function ( ContainerInterface $container ) : array { /** * Returns which countries and currency combinations can be used for AXO. */ @@ -163,7 +163,7 @@ return array( ); }, - 'axo.settings-conflict-notice' => static function ( ContainerInterface $container ) : string { + 'axo.settings-conflict-notice' => static function ( ContainerInterface $container ) : string { $settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' ); assert( $settings_notice_generator instanceof SettingsNoticeGenerator ); @@ -173,28 +173,21 @@ return array( return $settings_notice_generator->generate_settings_conflict_notice( $settings ); }, - 'axo.checkout-config-notice' => static function ( ContainerInterface $container ) : string { + 'axo.checkout-config-notice' => static function ( ContainerInterface $container ) : string { $settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' ); assert( $settings_notice_generator instanceof SettingsNoticeGenerator ); return $settings_notice_generator->generate_checkout_notice(); }, - 'axo.shipping-config-notice' => static function ( ContainerInterface $container ) : string { - $settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' ); - assert( $settings_notice_generator instanceof SettingsNoticeGenerator ); - - return $settings_notice_generator->generate_shipping_notice(); - }, - - 'axo.incompatible-plugins-notice' => static function ( ContainerInterface $container ) : string { + 'axo.incompatible-plugins-notice' => static function ( ContainerInterface $container ) : string { $settings_notice_generator = $container->get( 'axo.helpers.settings-notice-generator' ); assert( $settings_notice_generator instanceof SettingsNoticeGenerator ); return $settings_notice_generator->generate_incompatible_plugins_notice(); }, - 'axo.smart-button-location-notice' => static function ( ContainerInterface $container ) : string { + 'axo.smart-button-location-notice' => static function ( ContainerInterface $container ) : string { $settings = $container->get( 'wcgateway.settings' ); assert( $settings instanceof Settings ); @@ -222,10 +215,92 @@ return array( return '

' . $notice_content . '

'; }, - 'axo.endpoint.frontend-logger' => static function ( ContainerInterface $container ): FrontendLoggerEndpoint { + 'axo.endpoint.frontend-logger' => static function ( ContainerInterface $container ): FrontendLoggerEndpoint { return new FrontendLoggerEndpoint( $container->get( 'button.request-data' ), $container->get( 'woocommerce.logger.woocommerce' ) ); }, + + /** + * The list of Fastlane incompatible plugins. + * + * @returns array + */ + 'axo.fastlane-incompatible-plugins' => static function () : array { + /** + * Filters the list of Fastlane incompatible plugins. + */ + return apply_filters( + 'woocommerce_paypal_payments_fastlane_incompatible_plugins', + array( + array( + 'name' => 'Elementor', + 'is_active' => did_action( 'elementor/loaded' ), + ), + array( + 'name' => 'CheckoutWC', + 'is_active' => defined( 'CFW_NAME' ), + ), + array( + 'name' => 'Direct Checkout for WooCommerce', + 'is_active' => defined( 'QLWCDC_PLUGIN_NAME' ), + ), + array( + 'name' => 'Multi-Step Checkout for WooCommerce', + 'is_active' => class_exists( 'WPMultiStepCheckout' ), + ), + array( + 'name' => 'Fluid Checkout for WooCommerce', + 'is_active' => class_exists( 'FluidCheckout' ), + ), + array( + 'name' => 'MultiStep Checkout for WooCommerce', + 'is_active' => class_exists( 'THWMSCF_Multistep_Checkout' ), + ), + array( + 'name' => 'WooCommerce Subscriptions', + 'is_active' => class_exists( 'WC_Subscriptions' ), + ), + array( + 'name' => 'CartFlows', + 'is_active' => class_exists( 'Cartflows_Loader' ), + ), + array( + 'name' => 'FunnelKit Funnel Builder', + 'is_active' => class_exists( 'WFFN_Core' ), + ), + array( + 'name' => 'WooCommerce One Page Checkout', + 'is_active' => class_exists( 'PP_One_Page_Checkout' ), + ), + array( + 'name' => 'All Products for Woo Subscriptions', + 'is_active' => class_exists( 'WCS_ATT' ), + ), + ) + ); + }, + + 'axo.fastlane-incompatible-plugin-names' => static function ( ContainerInterface $container ) : array { + $incompatible_plugins = $container->get( 'axo.fastlane-incompatible-plugins' ); + + $active_plugins_list = array_filter( + $incompatible_plugins, + function( array $plugin ): bool { + return (bool) $plugin['is_active']; + } + ); + + if ( empty( $active_plugins_list ) ) { + return array(); + } + + return array_map( + function ( array $plugin ): string { + return "
  • {$plugin['name']}
  • "; + }, + $active_plugins_list + ); + }, ); diff --git a/modules/ppcp-axo/src/AxoModule.php b/modules/ppcp-axo/src/AxoModule.php index 70a6e3876..069860550 100644 --- a/modules/ppcp-axo/src/AxoModule.php +++ b/modules/ppcp-axo/src/AxoModule.php @@ -16,6 +16,7 @@ use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\Axo\Assets\AxoManager; use WooCommerce\PayPalCommerce\Axo\Gateway\AxoGateway; use WooCommerce\PayPalCommerce\Button\Assets\SmartButtonInterface; +use WooCommerce\PayPalCommerce\Button\Helper\ContextTrait; use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingOptionsRenderer; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; @@ -32,6 +33,7 @@ use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; */ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { use ModuleClassNameIdTrait; + use ContextTrait; /** * {@inheritDoc} @@ -73,7 +75,9 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { // Add the gateway in admin area. if ( is_admin() ) { - // $methods[] = $gateway; - Temporarily remove Fastlane from the payment gateway list in admin area. + if ( ! $this->is_wc_settings_payments_tab() ) { + $methods[] = $gateway; + } return $methods; } @@ -95,10 +99,6 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { return $methods; } - if ( ! $this->is_compatible_shipping_config() ) { - return $methods; - } - $methods[] = $gateway; return $methods; }, @@ -169,7 +169,7 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { || ! $c->get( 'axo.eligible' ) || 'continuation' === $c->get( 'button.context' ) || $subscription_helper->cart_contains_subscription() - || ! $this->is_compatible_shipping_config() ) { + ) { return; } @@ -353,7 +353,6 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { return ! is_user_logged_in() && CartCheckoutDetector::has_classic_checkout() - && $this->is_compatible_shipping_config() && $is_axo_enabled && $is_dcc_enabled && ! $this->is_excluded_endpoint(); @@ -410,15 +409,6 @@ class AxoModule implements ServiceModule, ExtendingModule, ExecutableModule { return is_wc_endpoint_url( 'order-pay' ); } - /** - * Condition to evaluate if the shipping configuration is compatible. - * - * @return bool - */ - private function is_compatible_shipping_config(): bool { - return ! wc_shipping_enabled() || ( wc_shipping_enabled() && ! wc_ship_to_billing_address_only() ); - } - /** * Outputs a meta tag to allow feature detection on certain pages. * diff --git a/modules/ppcp-axo/src/Helper/SettingsNoticeGenerator.php b/modules/ppcp-axo/src/Helper/SettingsNoticeGenerator.php index 503e135cb..aec8848a0 100644 --- a/modules/ppcp-axo/src/Helper/SettingsNoticeGenerator.php +++ b/modules/ppcp-axo/src/Helper/SettingsNoticeGenerator.php @@ -18,6 +18,22 @@ use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException; * Class SettingsNoticeGenerator */ class SettingsNoticeGenerator { + /** + * The list of Fastlane incompatible plugin names. + * + * @var string[] + */ + protected $incompatible_plugin_names; + + /** + * SettingsNoticeGenerator constructor. + * + * @param string[] $incompatible_plugin_names The list of Fastlane incompatible plugin names. + */ + public function __construct( array $incompatible_plugin_names ) { + $this->incompatible_plugin_names = $incompatible_plugin_names; + } + /** * Generates the full HTML of the notification. * @@ -87,54 +103,16 @@ class SettingsNoticeGenerator { return $notice_content ? '

    ' . $notice_content . '

    ' : ''; } - /** - * Generates the shipping notice. - * - * @return string - */ - public function generate_shipping_notice(): string { - $shipping_settings_link = admin_url( 'admin.php?page=wc-settings&tab=shipping§ion=options' ); - - $notice_content = ''; - - if ( wc_shipping_enabled() && wc_ship_to_billing_address_only() ) { - $notice_content = sprintf( - /* translators: %1$s: URL to the Shipping destination settings page. */ - __( - 'Warning: The Shipping destination of your store is currently configured to Force shipping to the customer billing address. To enable Fastlane and accelerate payments, the shipping destination must be configured either to Default to customer shipping address or Default to customer billing address so buyers can set separate billing and shipping details.', - 'woocommerce-paypal-payments' - ), - esc_url( $shipping_settings_link ) - ); - } - - return $notice_content ? '

    ' . $notice_content . '

    ' : ''; - } - /** * Generates the incompatible plugins notice. * * @return string */ public function generate_incompatible_plugins_notice(): string { - $incompatible_plugins = array( - 'Elementor' => did_action( 'elementor/loaded' ), - 'CheckoutWC' => defined( 'CFW_NAME' ), - ); - - $active_plugins_list = array_filter( $incompatible_plugins ); - - if ( empty( $active_plugins_list ) ) { + if ( empty( $this->incompatible_plugin_names ) ) { return ''; } - $incompatible_plugin_items = array_map( - function ( $plugin ) { - return "
  • {$plugin}
  • "; - }, - array_keys( $active_plugins_list ) - ); - $plugins_settings_link = esc_url( admin_url( 'plugins.php' ) ); $notice_content = sprintf( /* translators: %1$s: URL to the plugins settings page. %2$s: List of incompatible plugins. */ @@ -143,7 +121,7 @@ class SettingsNoticeGenerator { 'woocommerce-paypal-payments' ), $plugins_settings_link, - implode( '', $incompatible_plugin_items ) + implode( '', $this->incompatible_plugin_names ) ); return '

    ' . $notice_content . '

    '; diff --git a/modules/ppcp-button/resources/css/hosted-fields.scss b/modules/ppcp-button/resources/css/hosted-fields.scss index 578a70a09..54482b573 100644 --- a/modules/ppcp-button/resources/css/hosted-fields.scss +++ b/modules/ppcp-button/resources/css/hosted-fields.scss @@ -1,11 +1,10 @@ -#payment ul.payment_methods li img.ppcp-card-icon { - padding: 0 0 3px 3px; - max-height: 25px; - display: inline-block; +#payment ul.payment_methods [class*="payment_method_ppcp-"] label img { + max-height: 25px; + display: inline-block; } .payments-sdk-contingency-handler { - z-index: 1000 !important; + z-index: 1000 !important; } .ppcp-credit-card-gateway-form-field-disabled { 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-button/resources/js/modules/Helper/PayerData.js b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js index b9b84d14f..5695facb0 100644 --- a/modules/ppcp-button/resources/js/modules/Helper/PayerData.js +++ b/modules/ppcp-button/resources/js/modules/Helper/PayerData.js @@ -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 ); + } +} diff --git a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js index 54f4e123a..d492802f1 100644 --- a/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js +++ b/modules/ppcp-button/resources/js/modules/OnApproveHandler/onApproveForContinue.js @@ -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; } ); diff --git a/modules/ppcp-button/resources/js/modules/Preview/DummyPreviewButton.js b/modules/ppcp-button/resources/js/modules/Preview/DummyPreviewButton.js new file mode 100644 index 000000000..c780d188d --- /dev/null +++ b/modules/ppcp-button/resources/js/modules/Preview/DummyPreviewButton.js @@ -0,0 +1,52 @@ +import PreviewButton from './PreviewButton'; + +/** + * Dummy preview button, to use in case an APM button cannot be rendered + */ +export default class DummyPreviewButton extends PreviewButton { + #innerEl; + + constructor( args ) { + super( args ); + + this.selector = `${ args.selector }Dummy`; + this.label = args.label || 'Not Available'; + } + + createNewWrapper() { + const wrapper = super.createNewWrapper(); + wrapper.classList.add( 'ppcp-button-apm', 'ppcp-button-dummy' ); + + return wrapper; + } + + createButton( buttonConfig ) { + this.#innerEl?.remove(); + + this.#innerEl = document.createElement( 'div' ); + this.#innerEl.innerHTML = `
    ${ this.label }
    `; + + this._applyStyles( this.ppcpConfig?.button?.style ); + + this.domWrapper.appendChild( this.#innerEl ); + } + + /** + * Applies the button shape (rect/pill) to the dummy button + * + * @param {{shape: string, height: number|null}} style + * @private + */ + _applyStyles( style ) { + this.domWrapper.classList.remove( + 'ppcp-button-pill', + 'ppcp-button-rect' + ); + + this.domWrapper.classList.add( `ppcp-button-${ style.shape }` ); + + if ( style.height ) { + this.domWrapper.style.height = `${ style.height }px`; + } + } +} diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PreviewButton.js b/modules/ppcp-button/resources/js/modules/Preview/PreviewButton.js similarity index 75% rename from modules/ppcp-button/resources/js/modules/Renderer/PreviewButton.js rename to modules/ppcp-button/resources/js/modules/Preview/PreviewButton.js index cec951927..418bb57f5 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/PreviewButton.js +++ b/modules/ppcp-button/resources/js/modules/Preview/PreviewButton.js @@ -5,16 +5,21 @@ import merge from 'deepmerge'; */ class PreviewButton { /** - * @param {string} selector - CSS ID of the wrapper, including the `#` - * @param {Object} apiConfig - PayPal configuration object; retrieved via a - * widgetBuilder API method + * @param {string} selector - CSS ID of the wrapper, including the `#` + * @param {Object} apiConfig - PayPal configuration object; retrieved via a + * widgetBuilder API method + * @param {string} methodName - Name of the payment method, e.g. "Google Pay" */ - constructor( { selector, apiConfig } ) { + constructor( { selector, apiConfig, methodName = '' } ) { this.apiConfig = apiConfig; this.defaultAttributes = {}; this.buttonConfig = {}; this.ppcpConfig = {}; this.isDynamic = true; + this.methodName = methodName; + this.methodSlug = this.methodName + .toLowerCase() + .replace( /[^a-z]+/g, '' ); // The selector is usually overwritten in constructor of derived class. this.selector = selector; @@ -26,13 +31,16 @@ class PreviewButton { /** * Creates a new DOM node to contain the preview button. * - * @return {jQuery} Always a single jQuery element with the new DOM node. + * @return {HTMLElement} Always a single jQuery element with the new DOM node. */ createNewWrapper() { + const wrapper = document.createElement( 'div' ); const previewId = this.selector.replace( '#', '' ); - const previewClass = 'ppcp-button-apm'; + const previewClass = `ppcp-preview-button ppcp-button-apm ppcp-button-${ this.methodSlug }`; - return jQuery( `
    ` ); + wrapper.setAttribute( 'id', previewId ); + wrapper.setAttribute( 'class', previewClass ); + return wrapper; } /** @@ -109,10 +117,12 @@ class PreviewButton { console.error( 'Skip render, button is not configured yet' ); return; } + this.domWrapper = this.createNewWrapper(); - this.domWrapper.insertAfter( this.wrapper ); + this._insertWrapper(); } else { - this.domWrapper.empty().show(); + this._emptyWrapper(); + this._showWrapper(); } this.isVisible = true; @@ -151,16 +161,38 @@ class PreviewButton { * Using a timeout here will make the button visible again at the end of the current * event queue. */ - setTimeout( () => this.domWrapper.show() ); + setTimeout( () => this._showWrapper() ); } remove() { this.isVisible = false; if ( this.domWrapper ) { - this.domWrapper.hide().empty(); + this._hideWrapper(); + this._emptyWrapper(); } } + + _showWrapper() { + this.domWrapper.style.display = ''; + } + + _hideWrapper() { + this.domWrapper.style.display = 'none'; + } + + _emptyWrapper() { + this.domWrapper.innerHTML = ''; + } + + _insertWrapper() { + const wrapperElement = document.querySelector( this.wrapper ); + + wrapperElement.parentNode.insertBefore( + this.domWrapper, + wrapperElement.nextSibling + ); + } } export default PreviewButton; diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js b/modules/ppcp-button/resources/js/modules/Preview/PreviewButtonManager.js similarity index 91% rename from modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js rename to modules/ppcp-button/resources/js/modules/Preview/PreviewButtonManager.js index 1ea2f04bc..fbc958a0c 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/PreviewButtonManager.js +++ b/modules/ppcp-button/resources/js/modules/Preview/PreviewButtonManager.js @@ -1,7 +1,8 @@ import { loadCustomScript } from '@paypal/paypal-js'; -import widgetBuilder from './WidgetBuilder'; +import widgetBuilder from '../Renderer/WidgetBuilder'; import { debounce } from '../../../../../ppcp-blocks/resources/js/Helper/debounce'; import ConsoleLogger from '../../../../../ppcp-wc-gateway/resources/js/helper/ConsoleLogger'; +import DummyPreviewButton from './DummyPreviewButton'; /** * Manages all PreviewButton instances of a certain payment method on the page. @@ -26,6 +27,13 @@ class PreviewButtonManager { */ #onInit; + /** + * Initialize the new PreviewButtonManager. + * + * @param {string} methodName - Name of the payment method, e.g. "Google Pay" + * @param {Object} buttonConfig + * @param {Object} defaultAttributes + */ constructor( { methodName, buttonConfig, defaultAttributes } ) { // Define the payment method name in the derived class. this.methodName = methodName; @@ -102,27 +110,15 @@ class PreviewButtonManager { * * This dummy is only visible on the admin side, and not rendered on the front-end. * - * @todo Consider refactoring this into a new class that extends the PreviewButton class. * @param {string} wrapperId * @return {any} */ - createDummy( wrapperId ) { - const elButton = document.createElement( 'div' ); - elButton.classList.add( 'ppcp-button-apm', 'ppcp-button-dummy' ); - elButton.innerHTML = `${ - this.apiError ?? 'Not Available' - }`; - - document.querySelector( wrapperId ).appendChild( elButton ); - - const instDummy = { - setDynamic: () => instDummy, - setPpcpConfig: () => instDummy, - render: () => {}, - remove: () => {}, - }; - - return instDummy; + createDummyButtonInstance( wrapperId ) { + return new DummyPreviewButton( { + selector: wrapperId, + label: this.apiError, + methodName: this.methodName, + } ); } registerEventListeners() { @@ -319,6 +315,17 @@ class PreviewButtonManager { this.log( 'configureAllButtons', ppcpConfig ); Object.entries( this.buttons ).forEach( ( [ id, button ] ) => { + const limitWrapper = ppcpConfig.button?.wrapper; + + /** + * When the ppcpConfig object specifies a button wrapper, then ensure to limit preview + * changes to this individual wrapper. If no button wrapper is defined, the + * configuration is relevant for all buttons on the page. + */ + if ( limitWrapper && button.wrapper !== limitWrapper ) { + return; + } + this._configureButton( id, { ...ppcpConfig, button: { @@ -349,12 +356,11 @@ class PreviewButtonManager { let newInst; if ( this.apiConfig && 'object' === typeof this.apiConfig ) { - newInst = this.createButtonInstance( id ).setButtonConfig( - this.buttonConfig - ); + newInst = this.createButtonInstance( id ); } else { - newInst = this.createDummy( id ); + newInst = this.createDummyButtonInstance( id ); } + newInst.setButtonConfig( this.buttonConfig ); this.buttons[ id ] = newInst; } diff --git a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js index 8da7b6e24..3ce35c9b5 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/PaymentButton.js @@ -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. diff --git a/modules/ppcp-button/services.php b/modules/ppcp-button/services.php index 069355da6..891d4c0a9 100644 --- a/modules/ppcp-button/services.php +++ b/modules/ppcp-button/services.php @@ -320,6 +320,7 @@ return array( }, 'button.helper.messages-apply' => static function ( ContainerInterface $container ): MessagesApply { return new MessagesApply( + $container->get( 'api.paylater-countries' ), $container->get( 'api.shop.country' ) ); }, diff --git a/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php index 36069e463..a96302e65 100644 --- a/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php +++ b/modules/ppcp-button/src/Endpoint/SimulateCartEndpoint.php @@ -85,7 +85,8 @@ class SimulateCartEndpoint extends AbstractCartEndpoint { $this->add_products( $products ); $this->cart->calculate_totals(); - $total = (float) $this->cart->get_total( 'numeric' ); + $total = (float) $this->cart->get_total( 'numeric' ); + $shipping_fee = (float) $this->cart->get_shipping_total(); $this->restore_real_cart(); @@ -113,7 +114,7 @@ class SimulateCartEndpoint extends AbstractCartEndpoint { wp_send_json_success( array( 'total' => $total, - 'total_str' => ( new Money( $total, $currency_code ) )->value_str(), + 'shipping_fee' => $shipping_fee, 'currency_code' => $currency_code, 'country_code' => $shop_country_code, 'funding' => array( diff --git a/modules/ppcp-button/src/Helper/ContextTrait.php b/modules/ppcp-button/src/Helper/ContextTrait.php index 0c1bb4201..9f58cbcdf 100644 --- a/modules/ppcp-button/src/Helper/ContextTrait.php +++ b/modules/ppcp-button/src/Helper/ContextTrait.php @@ -243,4 +243,20 @@ trait ContextTrait { $screen = get_current_screen(); return $screen && $screen->is_block_editor(); } + + /** + * Checks if is WooCommerce Settings Payments tab screen (/wp-admin/admin.php?page=wc-settings&tab=checkout). + * + * @return bool + */ + protected function is_wc_settings_payments_tab(): bool { + if ( ! is_admin() || isset( $_GET['section'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + return false; + } + + $page = wc_clean( wp_unslash( $_GET['page'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification + $tab = wc_clean( wp_unslash( $_GET['tab'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification + + return $page === 'wc-settings' && $tab === 'checkout'; + } } diff --git a/modules/ppcp-button/src/Helper/MessagesApply.php b/modules/ppcp-button/src/Helper/MessagesApply.php index c0d0b1d6b..4f4a3d9f6 100644 --- a/modules/ppcp-button/src/Helper/MessagesApply.php +++ b/modules/ppcp-button/src/Helper/MessagesApply.php @@ -18,17 +18,9 @@ class MessagesApply { /** * In which countries credit messaging is available. * - * @var array + * @var string[] */ - private $countries = array( - 'US', - 'DE', - 'GB', - 'FR', - 'AU', - 'IT', - 'ES', - ); + private $allowed_countries; /** * 2-letter country code of the shop. @@ -40,10 +32,12 @@ class MessagesApply { /** * MessagesApply constructor. * - * @param string $country 2-letter country code of the shop. + * @param string[] $allowed_countries In which countries credit messaging is available. + * @param string $country 2-letter country code of the shop. */ - public function __construct( string $country ) { - $this->country = $country; + public function __construct( array $allowed_countries, string $country ) { + $this->allowed_countries = $allowed_countries; + $this->country = $country; } /** @@ -52,6 +46,6 @@ class MessagesApply { * @return bool */ public function for_country(): bool { - return in_array( $this->country, $this->countries, true ); + return in_array( $this->country, $this->allowed_countries, true ); } } diff --git a/modules/ppcp-card-fields/resources/js/Render.js b/modules/ppcp-card-fields/resources/js/Render.js index 9a35ff449..146396288 100644 --- a/modules/ppcp-card-fields/resources/js/Render.js +++ b/modules/ppcp-card-fields/resources/js/Render.js @@ -1,47 +1,35 @@ import { cardFieldStyles } from './CardFieldsHelper'; +import { hide } from '../../../ppcp-button/resources/js/modules/Helper/Hiding'; + +function renderField( cardField, inputField ) { + if ( ! inputField || inputField.hidden || ! cardField ) { + return; + } + + // Insert the PayPal card field after the original input field. + const styles = cardFieldStyles( inputField ); + cardField( { style: { input: styles } } ).render( inputField.parentNode ); + + // Hide the original input field. + hide( inputField, true ); + inputField.hidden = true; +} export function renderFields( cardFields ) { - const nameField = document.getElementById( - 'ppcp-credit-card-gateway-card-name' + renderField( + cardFields.NameField, + document.getElementById( 'ppcp-credit-card-gateway-card-name' ) ); - if ( nameField && nameField.hidden !== true ) { - const styles = cardFieldStyles( nameField ); - cardFields - .NameField( { style: { input: styles } } ) - .render( nameField.parentNode ); - nameField.hidden = true; - } - - const numberField = document.getElementById( - 'ppcp-credit-card-gateway-card-number' + renderField( + cardFields.NumberField, + document.getElementById( 'ppcp-credit-card-gateway-card-number' ) ); - if ( numberField && numberField.hidden !== true ) { - const styles = cardFieldStyles( numberField ); - cardFields - .NumberField( { style: { input: styles } } ) - .render( numberField.parentNode ); - numberField.hidden = true; - } - - const expiryField = document.getElementById( - 'ppcp-credit-card-gateway-card-expiry' + renderField( + cardFields.ExpiryField, + document.getElementById( 'ppcp-credit-card-gateway-card-expiry' ) ); - if ( expiryField && expiryField.hidden !== true ) { - const styles = cardFieldStyles( expiryField ); - cardFields - .ExpiryField( { style: { input: styles } } ) - .render( expiryField.parentNode ); - expiryField.hidden = true; - } - - const cvvField = document.getElementById( - 'ppcp-credit-card-gateway-card-cvc' + renderField( + cardFields.CVVField, + document.getElementById( 'ppcp-credit-card-gateway-card-cvc' ) ); - if ( cvvField && cvvField.hidden !== true ) { - const styles = cardFieldStyles( cvvField ); - cardFields - .CVVField( { style: { input: styles } } ) - .render( cvvField.parentNode ); - cvvField.hidden = true; - } } diff --git a/modules/ppcp-googlepay/assets/images/googlepay.png b/modules/ppcp-googlepay/assets/images/googlepay.png deleted file mode 100644 index b264fd0ee..000000000 Binary files a/modules/ppcp-googlepay/assets/images/googlepay.png and /dev/null differ diff --git a/modules/ppcp-googlepay/assets/images/googlepay.svg b/modules/ppcp-googlepay/assets/images/googlepay.svg new file mode 100644 index 000000000..0abef7bce --- /dev/null +++ b/modules/ppcp-googlepay/assets/images/googlepay.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + diff --git a/modules/ppcp-googlepay/extensions.php b/modules/ppcp-googlepay/extensions.php index f49376736..20b19510c 100644 --- a/modules/ppcp-googlepay/extensions.php +++ b/modules/ppcp-googlepay/extensions.php @@ -72,7 +72,7 @@ return array( 'googlepay_button_enabled' => array( 'title' => __( 'Google Pay Button', 'woocommerce-paypal-payments' ), 'title_html' => sprintf( - '%s', + '%s', $module_url, __( 'Google Pay', 'woocommerce-paypal-payments' ) ), @@ -117,7 +117,7 @@ return array( 'googlepay_button_enabled' => array( 'title' => __( 'Google Pay Button', 'woocommerce-paypal-payments' ), 'title_html' => sprintf( - '%s', + '%s', $module_url, __( 'Google Pay', 'woocommerce-paypal-payments' ) ), diff --git a/modules/ppcp-googlepay/resources/css/styles.scss b/modules/ppcp-googlepay/resources/css/styles.scss index 93cb05f47..3c6fe8912 100644 --- a/modules/ppcp-googlepay/resources/css/styles.scss +++ b/modules/ppcp-googlepay/resources/css/styles.scss @@ -13,6 +13,11 @@ outline-offset: -1px; border-radius: var(--apm-button-border-radius); } + + &.ppcp-preview-button.ppcp-button-dummy { + /* URL must specify the correct module-folder! */ + --apm-button-dummy-background: url(../../../ppcp-googlepay/assets/images/googlepay.png); + } } .wp-block-woocommerce-checkout, .wp-block-woocommerce-cart { diff --git a/modules/ppcp-googlepay/resources/js/Context/BaseHandler.js b/modules/ppcp-googlepay/resources/js/Context/BaseHandler.js index d61b674a7..d49bee615 100644 --- a/modules/ppcp-googlepay/resources/js/Context/BaseHandler.js +++ b/modules/ppcp-googlepay/resources/js/Context/BaseHandler.js @@ -1,5 +1,6 @@ import ErrorHandler from '../../../../ppcp-button/resources/js/modules/ErrorHandler'; import CartActionHandler from '../../../../ppcp-button/resources/js/modules/ActionHandler/CartActionHandler'; +import TransactionInfo from '../Helper/TransactionInfo'; class BaseHandler { constructor( buttonConfig, ppcpConfig, externalHandler ) { @@ -34,13 +35,14 @@ class BaseHandler { // handle script reload const data = result.data; + const transaction = new TransactionInfo( + data.total, + data.shipping_fee, + data.currency_code, + data.country_code + ); - resolve( { - countryCode: data.country_code, - currencyCode: data.currency_code, - totalPriceStatus: 'FINAL', - totalPrice: data.total_str, - } ); + resolve( transaction ); } ); } ); } diff --git a/modules/ppcp-googlepay/resources/js/Context/PayNowHandler.js b/modules/ppcp-googlepay/resources/js/Context/PayNowHandler.js index 79de6b39d..81d60b078 100644 --- a/modules/ppcp-googlepay/resources/js/Context/PayNowHandler.js +++ b/modules/ppcp-googlepay/resources/js/Context/PayNowHandler.js @@ -1,6 +1,7 @@ import Spinner from '../../../../ppcp-button/resources/js/modules/Helper/Spinner'; import BaseHandler from './BaseHandler'; import CheckoutActionHandler from '../../../../ppcp-button/resources/js/modules/ActionHandler/CheckoutActionHandler'; +import TransactionInfo from '../Helper/TransactionInfo'; class PayNowHandler extends BaseHandler { validateContext() { @@ -14,12 +15,14 @@ class PayNowHandler extends BaseHandler { return new Promise( async ( resolve, reject ) => { const data = this.ppcpConfig.pay_now; - resolve( { - countryCode: data.country_code, - currencyCode: data.currency_code, - totalPriceStatus: 'FINAL', - totalPrice: data.total_str, - } ); + const transaction = new TransactionInfo( + data.total, + data.shipping_fee, + data.currency_code, + data.country_code + ); + + resolve( transaction ); } ); } diff --git a/modules/ppcp-googlepay/resources/js/Context/SingleProductHandler.js b/modules/ppcp-googlepay/resources/js/Context/SingleProductHandler.js index a8aa6e8bd..670b9a7c0 100644 --- a/modules/ppcp-googlepay/resources/js/Context/SingleProductHandler.js +++ b/modules/ppcp-googlepay/resources/js/Context/SingleProductHandler.js @@ -3,6 +3,7 @@ import SimulateCart from '../../../../ppcp-button/resources/js/modules/Helper/Si import ErrorHandler from '../../../../ppcp-button/resources/js/modules/ErrorHandler'; import UpdateCart from '../../../../ppcp-button/resources/js/modules/Helper/UpdateCart'; import BaseHandler from './BaseHandler'; +import TransactionInfo from '../Helper/TransactionInfo'; class SingleProductHandler extends BaseHandler { validateContext() { @@ -42,12 +43,14 @@ class SingleProductHandler extends BaseHandler { this.ppcpConfig.ajax.simulate_cart.endpoint, this.ppcpConfig.ajax.simulate_cart.nonce ).simulate( ( data ) => { - resolve( { - countryCode: data.country_code, - currencyCode: data.currency_code, - totalPriceStatus: 'FINAL', - totalPrice: data.total_str, - } ); + const transaction = new TransactionInfo( + data.total, + data.shipping_fee, + data.currency_code, + data.country_code + ); + + resolve( transaction ); }, products ); } ); } 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..1e1933a10 --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/ContextBootstrap/CheckoutBootstrap.js @@ -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(); + } +} diff --git a/modules/ppcp-googlepay/resources/js/GooglepayButton.js b/modules/ppcp-googlepay/resources/js/GooglepayButton.js index 4b267c4e7..d4d9df55f 100644 --- a/modules/ppcp-googlepay/resources/js/GooglepayButton.js +++ b/modules/ppcp-googlepay/resources/js/GooglepayButton.js @@ -5,7 +5,10 @@ import { import PaymentButton from '../../../ppcp-button/resources/js/modules/Renderer/PaymentButton'; import widgetBuilder from '../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder'; import UpdatePaymentData from './Helper/UpdatePaymentData'; +import TransactionInfo from './Helper/TransactionInfo'; 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 +42,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 +62,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 +113,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 +423,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 +471,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.finalObject, + merchantInfo: this.googlePayConfig.merchantInfo, + callbackIntents, + emailRequired: true, + shippingAddressRequired: useShippingCallback, + shippingOptionRequired: useShippingCallback, + shippingAddressParameters: this.shippingAddressParameters(), + }; } //------------------------ @@ -481,6 +514,16 @@ class GooglepayButton extends PaymentButton { ).update( paymentData ); const transactionInfo = this.transactionInfo; + // Check, if the current context uses the WC cart. + const hasRealCart = [ + 'checkout-block', + 'checkout', + 'cart-block', + 'cart', + 'mini-cart', + 'pay-now', + ].includes( this.context ); + this.log( 'onPaymentDataChanged:updatedData', updatedData ); this.log( 'onPaymentDataChanged:transactionInfo', @@ -489,7 +532,6 @@ class GooglepayButton extends PaymentButton { updatedData.country_code = transactionInfo.countryCode; updatedData.currency_code = transactionInfo.currencyCode; - updatedData.total_str = transactionInfo.totalPrice; // Handle unserviceable address. if ( ! updatedData.shipping_options?.shippingOptions?.length ) { @@ -499,20 +541,37 @@ class GooglepayButton extends PaymentButton { return; } - switch ( paymentData.callbackTrigger ) { - case 'INITIALIZE': - case 'SHIPPING_ADDRESS': - paymentDataRequestUpdate.newShippingOptionParameters = - updatedData.shipping_options; - paymentDataRequestUpdate.newTransactionInfo = - this.calculateNewTransactionInfo( updatedData ); - break; - case 'SHIPPING_OPTION': - paymentDataRequestUpdate.newTransactionInfo = - this.calculateNewTransactionInfo( updatedData ); - break; + if ( + [ 'INITIALIZE', 'SHIPPING_ADDRESS' ].includes( + paymentData.callbackTrigger + ) + ) { + paymentDataRequestUpdate.newShippingOptionParameters = + this.sanitizeShippingOptions( + updatedData.shipping_options + ); } + if ( updatedData.total && hasRealCart ) { + transactionInfo.setTotal( + updatedData.total, + updatedData.shipping_fee + ); + + // This page contains a real cart and potentially a form for shipping options. + this.syncShippingOptionWithForm( + paymentData?.shippingOptionData?.id + ); + } else { + transactionInfo.shippingFee = this.getShippingCosts( + paymentData?.shippingOptionData?.id, + updatedData.shipping_options + ); + } + + paymentDataRequestUpdate.newTransactionInfo = + this.calculateNewTransactionInfo( transactionInfo ); + resolve( paymentDataRequestUpdate ); } catch ( error ) { this.error( 'Error during onPaymentDataChanged:', error ); @@ -521,6 +580,76 @@ class GooglepayButton extends PaymentButton { } ); } + /** + * Google Pay throws an error, when the shippingOptions entries contain + * custom properties. This function strips unsupported properties from the + * provided ajax response. + * + * @param {Object} responseData Data returned from the ajax endpoint. + * @return {Object} Sanitized object. + */ + sanitizeShippingOptions( responseData ) { + // Sanitize the shipping options. + const cleanOptions = responseData.shippingOptions.map( ( item ) => ( { + id: item.id, + label: item.label, + description: item.description, + } ) ); + + // Ensure that the default option is valid. + let defaultOptionId = responseData.defaultSelectedOptionId; + if ( ! cleanOptions.some( ( item ) => item.id === defaultOptionId ) ) { + defaultOptionId = cleanOptions[ 0 ].id; + } + + return { + defaultSelectedOptionId: defaultOptionId, + shippingOptions: cleanOptions, + }; + } + + /** + * Returns the shipping costs as numeric value. + * + * TODO - Move this to the PaymentButton base class + * + * @param {string} shippingId - The shipping method ID. + * @param {Object} shippingData - The PaymentDataRequest object that + * contains shipping options. + * @param {Array} shippingData.shippingOptions + * @param {string} shippingData.defaultSelectedOptionId + * + * @return {number} The shipping costs. + */ + getShippingCosts( + shippingId, + { shippingOptions = [], defaultSelectedOptionId = '' } = {} + ) { + if ( ! shippingOptions?.length ) { + this.log( 'Cannot calculate shipping cost: No Shipping Options' ); + return 0; + } + + const findOptionById = ( id ) => + shippingOptions.find( ( option ) => option.id === id ); + + const getValidShippingId = () => { + if ( + 'shipping_option_unselected' === shippingId || + ! findOptionById( shippingId ) + ) { + // Entered on initial call, and when changing the shipping country. + return defaultSelectedOptionId; + } + + return shippingId; + }; + + const currentOption = findOptionById( getValidShippingId() ); + + return Number( currentOption?.cost ) || 0; + } + unserviceableShippingAddressError() { return { reason: 'SHIPPING_ADDRESS_UNSERVICEABLE', @@ -529,13 +658,14 @@ class GooglepayButton extends PaymentButton { }; } - calculateNewTransactionInfo( updatedData ) { - return { - countryCode: updatedData.country_code, - currencyCode: updatedData.currency_code, - totalPriceStatus: 'FINAL', - totalPrice: updatedData.total_str, - }; + /** + * Recalculates and returns the plain transaction info object. + * + * @param {TransactionInfo} transactionInfo - Internal transactionInfo instance. + * @return {{totalPrice: string, countryCode: string, totalPriceStatus: string, currencyCode: string}} Updated details. + */ + calculateNewTransactionInfo( transactionInfo ) { + return transactionInfo.finalObject; } //------------------------ @@ -543,83 +673,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 ); - const confirmOrderResponse = await widgetBuilder.paypal - .Googlepay() - .confirmOrder( { - orderId: id, - paymentMethodData: paymentData.paymentMethodData, - } ); + return this.processPaymentResponse( + 'ERROR', + 'PAYMENT_AUTHORIZATION', + reason + ); + }; - this.log( - 'processPayment: confirmOrder', - confirmOrderResponse - ); + const checkPayPalApproval = async ( orderId ) => { + const confirmationData = { + orderId, + paymentMethodData: paymentData.paymentMethodData, + }; - /** Capture the Order on the Server */ - if ( confirmOrderResponse.status === 'APPROVED' ) { - let approveFailed = false; - await this.contextHandler.approveOrder( - { - orderID: id, - }, - { - // actions mock object. - restart: () => - new Promise( ( resolve, reject ) => { - approveFailed = true; - resolve(); - } ), - order: { - get: () => - new Promise( ( resolve, reject ) => { - resolve( null ); - } ), - }, - } - ); + const confirmOrderResponse = await widgetBuilder.paypal + .Googlepay() + .confirmOrder( confirmationData ); - if ( ! approveFailed ) { - resolve( this.processPaymentResponse( 'SUCCESS' ) ); - } else { - resolve( - this.processPaymentResponse( - 'ERROR', - 'PAYMENT_AUTHORIZATION', - 'FAILED TO APPROVE' - ) - ); - } - } else { - resolve( - this.processPaymentResponse( - 'ERROR', - 'PAYMENT_AUTHORIZATION', - 'TRANSACTION FAILED' - ) - ); + this.log( 'confirmOrder', confirmOrderResponse ); + + return 'APPROVED' === confirmOrderResponse?.status; + }; + + /** + * This approval mainly confirms that the orderID is valid. + * + * It's still needed because this handler redirects to the checkout page if the server-side + * approval was successful. + * + * @param {string} orderID + */ + const approveOrderServerSide = async ( orderID ) => { + let isApproved = true; + + this.log( 'approveOrder', orderID ); + + await this.contextHandler.approveOrder( + { orderID, payer }, + { + restart: () => + new Promise( ( resolve ) => { + isApproved = false; + resolve(); + } ), + order: { + get: () => + new Promise( ( resolve ) => { + resolve( null ); + } ), + }, } - } catch ( err ) { - resolve( - this.processPaymentResponse( - 'ERROR', - 'PAYMENT_AUTHORIZATION', - err.message - ) - ); + ); + + return isApproved; + }; + + const processPaymentPromise = async ( resolve ) => { + const id = await this.contextHandler.createOrder(); + + this.log( 'createOrder', id ); + + const isApprovedByPayPal = await checkPayPalApproval( id ); + + if ( ! isApprovedByPayPal ) { + resolve( paymentError( 'TRANSACTION FAILED' ) ); + + return; } + + // This must be the last step in the process, as it initiates a redirect. + const success = await approveOrderServerSide( id ); + + if ( success ) { + resolve( this.processPaymentResponse( 'SUCCESS' ) ); + } else { + resolve( paymentError( 'FAILED TO APPROVE' ) ); + } + }; + + const addBillingDataToSession = () => { + moduleStorage.setPayer( payer ); + setPayerData( payer ); + }; + + return new Promise( async ( resolve ) => { + try { + addBillingDataToSession(); + await processPaymentPromise( resolve ); + } catch ( err ) { + resolve( paymentError( err.message ) ); + } + + this.logGroup(); } ); } @@ -639,6 +797,55 @@ class GooglepayButton extends PaymentButton { return response; } + + /** + * Updates the shipping option in the checkout form, if a form with shipping options is + * detected. + * + * @param {string} shippingOption - The shipping option ID, e.g. "flat_rate:4". + * @return {boolean} - True if a shipping option was found and selected, false otherwise. + */ + syncShippingOptionWithForm( shippingOption ) { + const wrappers = [ + // Classic checkout, Classic cart. + '.woocommerce-shipping-methods', + // Block checkout. + '.wc-block-components-shipping-rates-control', + // Block cart. + '.wc-block-components-totals-shipping', + ]; + + const sanitizedShippingOption = shippingOption.replace( /"/g, '' ); + + // Check for radio buttons with shipping options. + for ( const wrapper of wrappers ) { + const selector = `${ wrapper } input[type="radio"][value="${ sanitizedShippingOption }"]`; + const radioInput = document.querySelector( selector ); + + if ( radioInput ) { + radioInput.click(); + return true; + } + } + + // Check for select list with shipping options. + for ( const wrapper of wrappers ) { + const selector = `${ wrapper } select option[value="${ sanitizedShippingOption }"]`; + const selectOption = document.querySelector( selector ); + + if ( selectOption ) { + const selectElement = selectOption.closest( 'select' ); + + if ( selectElement ) { + selectElement.value = sanitizedShippingOption; + selectElement.dispatchEvent( new Event( 'change' ) ); + return true; + } + } + } + + return false; + } } export default GooglepayButton; 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; diff --git a/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js b/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js new file mode 100644 index 000000000..9216ad7c9 --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/Helper/TransactionInfo.js @@ -0,0 +1,73 @@ +export default class TransactionInfo { + #country = ''; + #currency = ''; + #amount = 0; + #shippingFee = 0; + + constructor( total, shippingFee, currency, country ) { + this.#country = country; + this.#currency = currency; + + this.shippingFee = shippingFee; + this.amount = total - shippingFee; + } + + set amount( newAmount ) { + this.#amount = this.toAmount( newAmount ); + } + + get amount() { + return this.#amount; + } + + set shippingFee( newCost ) { + this.#shippingFee = this.toAmount( newCost ); + } + + get shippingFee() { + return this.#shippingFee; + } + + get currencyCode() { + return this.#currency; + } + + get countryCode() { + return this.#country; + } + + get totalPrice() { + const total = this.#amount + this.#shippingFee; + + return total.toFixed( 2 ); + } + + get finalObject() { + return { + countryCode: this.countryCode, + currencyCode: this.currencyCode, + totalPriceStatus: 'FINAL', + totalPrice: this.totalPrice, + }; + } + + /** + * Converts the value to a number and rounds to a precision of 2 digits. + * + * @param {any} value - The value to sanitize. + * @return {number} Numeric value. + */ + toAmount( value ) { + value = Number( value ) || 0; + return Math.round( value * 100 ) / 100; + } + + setTotal( totalPrice, shippingFee ) { + totalPrice = this.toAmount( totalPrice ); + + if ( totalPrice ) { + this.shippingFee = shippingFee; + this.amount = totalPrice - this.shippingFee; + } + } +} diff --git a/modules/ppcp-googlepay/resources/js/Preview/GooglePayPreviewButton.js b/modules/ppcp-googlepay/resources/js/Preview/GooglePayPreviewButton.js new file mode 100644 index 000000000..e6b27ee55 --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/Preview/GooglePayPreviewButton.js @@ -0,0 +1,46 @@ +import GooglepayButton from '../GooglepayButton'; +import PreviewButton from '../../../../ppcp-button/resources/js/modules/Preview/PreviewButton'; + +/** + * A single GooglePay preview button instance. + */ +export default class GooglePayPreviewButton extends PreviewButton { + constructor( args ) { + super( args ); + + this.selector = `${ args.selector }GooglePay`; + this.defaultAttributes = { + button: { + style: { + type: 'pay', + color: 'black', + language: 'en', + }, + }, + }; + } + + createButton( buttonConfig ) { + const button = new GooglepayButton( + 'preview', + null, + buttonConfig, + this.ppcpConfig + ); + + button.init( this.apiConfig ); + } + + /** + * Merge form details into the config object for preview. + * Mutates the previewConfig object; no return value. + * @param buttonConfig + * @param ppcpConfig + */ + dynamicPreviewConfig( buttonConfig, ppcpConfig ) { + // Merge the current form-values into the preview-button configuration. + if ( ppcpConfig.button && buttonConfig.button ) { + Object.assign( buttonConfig.button.style, ppcpConfig.button.style ); + } + } +} diff --git a/modules/ppcp-googlepay/resources/js/Preview/GooglePayPreviewButtonManager.js b/modules/ppcp-googlepay/resources/js/Preview/GooglePayPreviewButtonManager.js new file mode 100644 index 000000000..a3e9f66af --- /dev/null +++ b/modules/ppcp-googlepay/resources/js/Preview/GooglePayPreviewButtonManager.js @@ -0,0 +1,57 @@ +import PreviewButtonManager from '../../../../ppcp-button/resources/js/modules/Preview/PreviewButtonManager'; +import GooglePayPreviewButton from './GooglePayPreviewButton'; + +/** + * Manages all GooglePay preview buttons on this page. + */ +export default class GooglePayPreviewButtonManager extends PreviewButtonManager { + constructor() { + const args = { + methodName: 'GooglePay', + buttonConfig: window.wc_ppcp_googlepay_admin, + }; + + super( args ); + } + + /** + * Responsible for fetching and returning the PayPal configuration object for this payment + * method. + * + * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. + * @return {Promise<{}>} + */ + async fetchConfig( payPal ) { + const apiMethod = payPal?.Googlepay()?.config; + + if ( ! apiMethod ) { + this.error( + 'configuration object cannot be retrieved from PayPal' + ); + return {}; + } + + try { + return await apiMethod(); + } catch ( error ) { + if ( error.message.includes( 'Not Eligible' ) ) { + this.apiError = 'Not Eligible'; + } + return null; + } + } + + /** + * This method is responsible for creating a new PreviewButton instance and returning it. + * + * @param {string} wrapperId - CSS ID of the wrapper element. + * @return {GooglePayPreviewButton} + */ + createButtonInstance( wrapperId ) { + return new GooglePayPreviewButton( { + selector: wrapperId, + apiConfig: this.apiConfig, + methodName: this.methodName, + } ); + } +} diff --git a/modules/ppcp-googlepay/resources/js/boot-admin.js b/modules/ppcp-googlepay/resources/js/boot-admin.js index 7b5342078..953a6088e 100644 --- a/modules/ppcp-googlepay/resources/js/boot-admin.js +++ b/modules/ppcp-googlepay/resources/js/boot-admin.js @@ -1,5 +1,4 @@ -import PreviewButtonManager from '../../../ppcp-button/resources/js/modules/Renderer/PreviewButtonManager'; -import GooglePayPreviewButton from './GooglepayPreviewButton'; +import GooglePayPreviewButtonManager from './Preview/GooglePayPreviewButtonManager'; /** * Accessor that creates and returns a single PreviewButtonManager instance. @@ -13,59 +12,5 @@ const buttonManager = () => { return GooglePayPreviewButtonManager.instance; }; -/** - * Manages all GooglePay preview buttons on this page. - */ -class GooglePayPreviewButtonManager extends PreviewButtonManager { - constructor() { - const args = { - methodName: 'GooglePay', - buttonConfig: window.wc_ppcp_googlepay_admin, - }; - - super( args ); - } - - /** - * Responsible for fetching and returning the PayPal configuration object for this payment - * method. - * - * @param {{}} payPal - The PayPal SDK object provided by WidgetBuilder. - * @return {Promise<{}>} Promise that resolves when API configuration is available. - */ - async fetchConfig( payPal ) { - const apiMethod = payPal?.Googlepay()?.config; - - if ( ! apiMethod ) { - this.error( - 'configuration object cannot be retrieved from PayPal' - ); - return {}; - } - - try { - return await apiMethod(); - } catch ( error ) { - if ( error.message.includes( 'Not Eligible' ) ) { - this.apiError = 'Not Eligible'; - } - return null; - } - } - - /** - * This method is responsible for creating a new PreviewButton instance and returning it. - * - * @param {string} wrapperId - CSS ID of the wrapper element. - * @return {GooglePayPreviewButton} The new preview button instance. - */ - createButtonInstance( wrapperId ) { - return new GooglePayPreviewButton( { - selector: wrapperId, - apiConfig: this.apiConfig, - } ); - } -} - // Initialize the preview button manager. buttonManager(); diff --git a/modules/ppcp-googlepay/resources/js/boot.js b/modules/ppcp-googlepay/resources/js/boot.js index 99dd414f5..fb9e8e313 100644 --- a/modules/ppcp-googlepay/resources/js/boot.js +++ b/modules/ppcp-googlepay/resources/js/boot.js @@ -1,28 +1,62 @@ +/** + * Initialize the GooglePay module in the front end. + * In some cases, this module is loaded when the `window.PayPalCommerceGateway` object is not + * present. In that case, the page does not contain a Google Pay button, but some other logic + * that is related to Google Pay (e.g., the CheckoutBootstrap module) + * + * @file + */ + import { loadCustomScript } from '@paypal/paypal-js'; import { loadPaypalScript } from '../../../ppcp-button/resources/js/modules/Helper/ScriptLoading'; import GooglepayManager from './GooglepayManager'; import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Helper/ButtonRefreshHelper'; +import { CheckoutBootstrap } from './ContextBootstrap/CheckoutBootstrap'; +import moduleStorage from './Helper/GooglePayStorage'; -( function ( { buttonConfig, ppcpConfig, jQuery } ) { - let manager; +( function ( { buttonConfig, ppcpConfig = {} } ) { + const context = ppcpConfig.context; - const bootstrap = function () { - manager = new GooglepayManager( buttonConfig, ppcpConfig ); - manager.init(); - }; - - setupButtonEvents( function () { - if ( manager ) { - manager.reinit(); + function bootstrapPayButton() { + if ( ! buttonConfig || ! ppcpConfig ) { + return; } - } ); + + const manager = new GooglepayManager( buttonConfig, ppcpConfig ); + manager.init(); + + setupButtonEvents( function () { + manager.reinit(); + } ); + } + + function bootstrapCheckout() { + if ( context && ! [ 'continuation', 'checkout' ].includes( context ) ) { + // Context must be missing/empty, or "continuation"/"checkout" to proceed. + return; + } + if ( ! CheckoutBootstrap.isPageWithCheckoutForm() ) { + return; + } + + const checkoutBootstrap = new CheckoutBootstrap( moduleStorage ); + checkoutBootstrap.init(); + } + + function bootstrap() { + bootstrapPayButton(); + bootstrapCheckout(); + } document.addEventListener( 'DOMContentLoaded', () => { - if ( - typeof buttonConfig === 'undefined' || - typeof ppcpConfig === 'undefined' - ) { - // No PayPal buttons present on this page. + if ( ! buttonConfig || ! ppcpConfig ) { + /* + * No PayPal buttons present on this page, but maybe a bootstrap module needs to be + * initialized. Skip loading the SDK or gateway configuration, and directly initialize + * the module. + */ + bootstrap(); + return; } @@ -52,5 +86,4 @@ import { setupButtonEvents } from '../../../ppcp-button/resources/js/modules/Hel } )( { buttonConfig: window.wc_ppcp_googlepay, ppcpConfig: window.PayPalCommerceGateway, - jQuery: window.jQuery, } ); diff --git a/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php b/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php index 27da5ef48..e489bc771 100644 --- a/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php +++ b/modules/ppcp-googlepay/src/Endpoint/UpdatePaymentDataEndpoint.php @@ -90,7 +90,8 @@ class UpdatePaymentDataEndpoint { WC()->cart->calculate_fees(); WC()->cart->calculate_totals(); - $total = (float) WC()->cart->get_total( 'numeric' ); + $total = (float) WC()->cart->get_total( 'numeric' ); + $shipping_fee = (float) WC()->cart->get_shipping_total(); // Shop settings. $base_location = wc_get_base_location(); @@ -100,7 +101,7 @@ class UpdatePaymentDataEndpoint { wp_send_json_success( array( 'total' => $total, - 'total_str' => ( new Money( $total, $currency_code ) )->value_str(), + 'shipping_fee' => $shipping_fee, 'currency_code' => $currency_code, 'country_code' => $shop_country_code, 'shipping_options' => $this->get_shipping_options(), @@ -146,6 +147,7 @@ class UpdatePaymentDataEndpoint { wc_price( (float) $rate->get_cost(), array( 'currency' => get_woocommerce_currency() ) ) ) ), + 'cost' => $rate->get_cost(), ); } diff --git a/modules/ppcp-googlepay/src/GooglePayGateway.php b/modules/ppcp-googlepay/src/GooglePayGateway.php index cef3916d9..16fe5f690 100644 --- a/modules/ppcp-googlepay/src/GooglePayGateway.php +++ b/modules/ppcp-googlepay/src/GooglePayGateway.php @@ -114,7 +114,7 @@ class GooglePayGateway extends WC_Payment_Gateway { $this->description = $this->get_option( 'description', '' ); $this->module_url = $module_url; - $this->icon = esc_url( $this->module_url ) . 'assets/images/googlepay.png'; + $this->icon = esc_url( $this->module_url ) . 'assets/images/googlepay.svg'; $this->init_form_fields(); $this->init_settings(); diff --git a/modules/ppcp-googlepay/src/GooglepayModule.php b/modules/ppcp-googlepay/src/GooglepayModule.php index 0738dfec4..1b57a816a 100644 --- a/modules/ppcp-googlepay/src/GooglepayModule.php +++ b/modules/ppcp-googlepay/src/GooglepayModule.php @@ -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. diff --git a/modules/ppcp-onboarding/resources/js/onboarding.js b/modules/ppcp-onboarding/resources/js/onboarding.js index 905c66c65..5a6ab333a 100644 --- a/modules/ppcp-onboarding/resources/js/onboarding.js +++ b/modules/ppcp-onboarding/resources/js/onboarding.js @@ -326,7 +326,9 @@ window.ppcp_onboarding_productionCallback = function ( ...args ) { isDisconnecting = true; - document.querySelector( '.woocommerce-save-button' ).click(); + const saveButton = document.querySelector( '.woocommerce-save-button' ); + saveButton.removeAttribute( 'disabled' ); + saveButton.click(); }; // Prevent the message about unsaved checkbox/radiobutton when reloading the page. diff --git a/modules/ppcp-status-report/src/StatusReportModule.php b/modules/ppcp-status-report/src/StatusReportModule.php index 8aaf8fc6c..da015d586 100644 --- a/modules/ppcp-status-report/src/StatusReportModule.php +++ b/modules/ppcp-status-report/src/StatusReportModule.php @@ -181,6 +181,38 @@ class StatusReportModule implements ServiceModule, ExtendingModule, ExecutableMo $subscriptions_mode_settings ), ), + array( + 'label' => esc_html__( 'PayPal Shipping Callback', 'woocommerce-paypal-payments' ), + 'exported_label' => 'PayPal Shipping Callback', + 'description' => esc_html__( 'Whether the "Require final confirmation on checkout" setting is disabled.', 'woocommerce-paypal-payments' ), + 'value' => $this->bool_to_html( + $settings->has( 'blocks_final_review_enabled' ) && ! $settings->get( 'blocks_final_review_enabled' ) + ), + ), + array( + 'label' => esc_html__( 'Apple Pay', 'woocommerce-paypal-payments' ), + 'exported_label' => 'Apple Pay', + 'description' => esc_html__( 'Whether Apple Pay is enabled.', 'woocommerce-paypal-payments' ), + 'value' => $this->bool_to_html( + $settings->has( 'applepay_button_enabled' ) && $settings->get( 'applepay_button_enabled' ) + ), + ), + array( + 'label' => esc_html__( 'Google Pay', 'woocommerce-paypal-payments' ), + 'exported_label' => 'Google Pay', + 'description' => esc_html__( 'Whether Google Pay is enabled.', 'woocommerce-paypal-payments' ), + 'value' => $this->bool_to_html( + $settings->has( 'googlepay_button_enabled' ) && $settings->get( 'googlepay_button_enabled' ) + ), + ), + array( + 'label' => esc_html__( 'Fastlane', 'woocommerce-paypal-payments' ), + 'exported_label' => 'Fastlane', + 'description' => esc_html__( 'Whether Fastlane is enabled.', 'woocommerce-paypal-payments' ), + 'value' => $this->bool_to_html( + $settings->has( 'axo_enabled' ) && $settings->get( 'axo_enabled' ) + ), + ), ); echo wp_kses_post( diff --git a/modules/ppcp-wc-gateway/resources/css/common.scss b/modules/ppcp-wc-gateway/resources/css/common.scss index ac4bcab32..595f3eba7 100644 --- a/modules/ppcp-wc-gateway/resources/css/common.scss +++ b/modules/ppcp-wc-gateway/resources/css/common.scss @@ -31,9 +31,25 @@ $background-ident-color: #fbfbfb; &.ppcp-button-dummy { display: flex; + min-height: 25px; align-items: center; justify-content: center; background: #0001; + position: relative; + + &:before { + content: ''; + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 42px; + height: 24px; + background-image: var(--apm-button-dummy-background, none); + background-repeat: no-repeat; + background-size: contain; + background-position: center left; + } } } @@ -82,6 +98,14 @@ $background-ident-color: #fbfbfb; } } +.ppcp-notice-success { + border-left-color: #00a32a; + + .highlight { + color: #00a32a; + } +} + .ppcp-notice-warning { border-left-color: #dba617; 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; + } + } } diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index 9cb8dc0c0..6ca2ecafe 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -27,6 +27,7 @@ use WooCommerce\PayPalCommerce\Onboarding\State; use WooCommerce\PayPalCommerce\WcGateway\Admin\RenderReauthorizeAction; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\CaptureCardPayment; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Helper\CartCheckoutDetector; use WooCommerce\PayPalCommerce\WcGateway\Helper\FeesUpdater; use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper; use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; @@ -303,6 +304,37 @@ return array( static function ( ContainerInterface $container ): AuthorizeOrderActionNotice { return new AuthorizeOrderActionNotice(); }, + 'wcgateway.notice.checkout-blocks' => + static function ( ContainerInterface $container ): string { + $settings = $container->get( 'wcgateway.settings' ); + assert( $settings instanceof Settings ); + + $axo_available = $container->has( 'axo.available' ) && $container->get( 'axo.available' ); + $axo_enabled = $settings->has( 'axo_enabled' ) && $settings->get( 'axo_enabled' ); + + if ( $axo_available && $axo_enabled ) { + return ''; + } + + if ( CartCheckoutDetector::has_block_checkout() ) { + return ''; + } + + $checkout_page_link = esc_url( get_edit_post_link( wc_get_page_id( 'checkout' ) ) ?? '' ); + $instructions_link = 'https://woocommerce.com/document/cart-checkout-blocks-status/#using-the-cart-and-checkout-blocks'; + + $notice_content = sprintf( + /* translators: %1$s: URL to the Checkout edit page. %2$s: URL to the WooCommerce Checkout instructions. */ + __( + 'Info: The Checkout page of your store currently uses a classic checkout layout or a custom checkout widget. Advanced Card Processing supports the new Checkout block which improves conversion rates. See this page for instructions on how to upgrade to the new Checkout layout.', + 'woocommerce-paypal-payments' + ), + esc_url( $checkout_page_link ), + esc_url( $instructions_link ) + ); + + return '

    ' . $notice_content . '

    '; + }, 'wcgateway.settings.sections-renderer' => static function ( ContainerInterface $container ): SectionsRenderer { return new SectionsRenderer( $container->get( 'wcgateway.current-ppcp-settings-page-id' ), @@ -545,6 +577,17 @@ return array( 'requirements' => array(), 'gateway' => 'paypal', ), + 'dcc_block_checkout_notice' => array( + 'heading' => '', + 'html' => $container->get( 'wcgateway.notice.checkout-blocks' ), + 'type' => 'ppcp-html', + 'classes' => array(), + 'screens' => array( + State::STATE_ONBOARDED, + ), + 'requirements' => array( 'dcc' ), + 'gateway' => 'dcc', + ), 'dcc_enabled' => array( 'title' => __( 'Enable/Disable', 'woocommerce-paypal-payments' ), 'desc_tip' => true, diff --git a/modules/ppcp-wc-gateway/src/Settings/Fields/card-button-fields.php b/modules/ppcp-wc-gateway/src/Settings/Fields/card-button-fields.php index 1b8d13d9c..1bb6c24b8 100644 --- a/modules/ppcp-wc-gateway/src/Settings/Fields/card-button-fields.php +++ b/modules/ppcp-wc-gateway/src/Settings/Fields/card-button-fields.php @@ -32,7 +32,7 @@ return function ( ContainerInterface $container, array $fields ): array { ), '', '', - '' + '
    ' ), 'type' => 'ppcp-heading', 'screens' => array( diff --git a/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php b/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php index 06b4f51ce..0090b6632 100644 --- a/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php +++ b/modules/ppcp-wc-gateway/src/Settings/Fields/pay-later-tab-fields.php @@ -847,7 +847,7 @@ return function ( ContainerInterface $container, array $fields ): array { __( 'When enabled, a %1$sPay Later button%2$s is displayed for eligible customers.%3$sPayPal buttons must be enabled to display the Pay Later button.', 'woocommerce-paypal-payments' ), '', '', - '' + '
    ' ), ), 'pay_later_button_enabled' => array( diff --git a/modules/ppcp-wc-gateway/src/Settings/Fields/paypal-smart-button-fields.php b/modules/ppcp-wc-gateway/src/Settings/Fields/paypal-smart-button-fields.php index 3a2513e98..011cfd192 100644 --- a/modules/ppcp-wc-gateway/src/Settings/Fields/paypal-smart-button-fields.php +++ b/modules/ppcp-wc-gateway/src/Settings/Fields/paypal-smart-button-fields.php @@ -233,7 +233,7 @@ return function ( ContainerInterface $container, array $fields ): array { ), '', '', - '' + '
    ' ), 'type' => 'ppcp-heading', 'screens' => array( diff --git a/woocommerce-paypal-payments.php b/woocommerce-paypal-payments.php index 241de3af3..978aaf017 100644 --- a/woocommerce-paypal-payments.php +++ b/woocommerce-paypal-payments.php @@ -223,22 +223,6 @@ define( 'PPCP_PAYPAL_BN_CODE', 'Woo_PPCP' ); } ); - add_action( - 'in_plugin_update_message-woocommerce-paypal-payments/woocommerce-paypal-payments.php', - static function( array $plugin_data, \stdClass $new_data ) { - if ( version_compare( $plugin_data['Version'], '3.0.0', '<' ) && - version_compare( $new_data->new_version, '3.0.0', '>=' ) ) { - printf( - '
    %s: %s', - esc_html__( 'Warning', 'woocommerce-paypal-payments' ), - esc_html__( 'WooCommerce PayPal Payments version 3.0.0 contains significant changes that may impact your website. We strongly recommend reviewing the changes and testing the update on a staging site before updating it on your production environment.', 'woocommerce-paypal-payments' ) - ); - } - }, - 10, - 2 - ); - /** * Check if WooCommerce is active. *