diff --git a/modules/ppcp-button/resources/js/button.js b/modules/ppcp-button/resources/js/button.js index 505fa60aff..3d2d2bdf7b 100644 --- a/modules/ppcp-button/resources/js/button.js +++ b/modules/ppcp-button/resources/js/button.js @@ -1,3 +1,5 @@ +/* global paypal */ + import MiniCartBootstap from './modules/ContextBootstrap/MiniCartBootstap'; import SingleProductBootstap from './modules/ContextBootstrap/SingleProductBootstap'; import CartBootstrap from './modules/ContextBootstrap/CartBootstap'; diff --git a/modules/ppcp-button/resources/js/modules/Renderer/CardFieldsRenderer.js b/modules/ppcp-button/resources/js/modules/Renderer/CardFieldsRenderer.js index 4a0ec09f2c..940cd78992 100644 --- a/modules/ppcp-button/resources/js/modules/Renderer/CardFieldsRenderer.js +++ b/modules/ppcp-button/resources/js/modules/Renderer/CardFieldsRenderer.js @@ -1,7 +1,36 @@ import { show } from '../Helper/Hiding'; -import { renderFields } from '../../../../../ppcp-card-fields/resources/js/Render'; +import { cardFieldStyles } from '../../../../../ppcp-card-fields/resources/js/CardFieldsHelper'; + +/** + * @typedef {'NameField'|'NumberField'|'ExpiryField'|'CVVField'} FieldName + */ + +/** + * @typedef {Object} FieldInfo + * @property {FieldName} name - The field name, a valid property of the CardField instance. + * @property {HTMLElement} wrapper - The field's wrapper element (parent of `el`). + * @property {HTMLElement} el - The current input field, which is replaced by `insertField()`. + * @property {Object} options - Rendering options passed to the CardField instance. + */ + +/** + * @typedef {Object} CardFields + * @property {() => boolean} isEligible + * @property {() => Promise} submit + * @property {(options: {}) => {}} NameField + * @property {(options: {}) => {}} NumberField + * @property {(options: {}) => {}} ExpiryField + * @property {(options: {}) => {}} CVVField + */ class CardFieldsRenderer { + /** + * A Map that contains details about all input fields for the card checkout. + * + * @type {Map|null} + */ + #fields = null; + constructor( defaultConfig, errorHandler, @@ -18,6 +47,50 @@ class CardFieldsRenderer { this.onCardFieldsBeforeSubmit = onCardFieldsBeforeSubmit; } + /** + * Returns a Map with details about all form fields for the CardField element. + * + * @return {Map} + */ + get fieldInfos() { + if ( ! this.#fields ) { + this.#fields = new Map(); + + const domFields = { + NameField: 'ppcp-credit-card-gateway-card-name', + NumberField: 'ppcp-credit-card-gateway-card-number', + ExpiryField: 'ppcp-credit-card-gateway-card-expiry', + CVVField: 'ppcp-credit-card-gateway-card-cvc', + }; + + Object.entries( domFields ).forEach( ( [ fieldName, fieldId ] ) => { + const el = document.getElementById( fieldId ); + if ( ! el ) { + return; + } + + const wrapper = el.parentNode; + const styles = cardFieldStyles( el ); + const options = { + style: { input: styles }, + }; + + if ( el.getAttribute( 'placeholder' ) ) { + options.placeholder = el.getAttribute( 'placeholder' ); + } + + this.#fields.set( fieldName, { + name: fieldName, + wrapper, + options, + el, + } ); + } ); + } + + return this.#fields; + } + render( wrapper, contextConfig ) { if ( ( this.defaultConfig.context !== 'checkout' && @@ -45,20 +118,10 @@ class CardFieldsRenderer { hideDccGateway.parentNode.removeChild( hideDccGateway ); } - const cardFields = paypal.CardFields( { - createOrder: contextConfig.createOrder, - onApprove( data ) { - return contextConfig.onApprove( data ); - }, - onError( error ) { - console.error( error ); - this.spinner.unblock(); - }, - } ); + const cardFields = this.createInstance( contextConfig ); if ( cardFields.isEligible() ) { - renderFields( cardFields ); - document.dispatchEvent( new CustomEvent( 'hosted_fields_loaded' ) ); + this.insertAllFields( cardFields ); } gateWayBox.style.display = oldDisplayStyle; @@ -100,6 +163,7 @@ class CardFieldsRenderer { cardFields.submit().catch( ( error ) => { this.spinner.unblock(); + console.error( error ); this.errorHandler.message( this.defaultConfig.hosted_fields.labels.fields_not_valid @@ -109,7 +173,70 @@ class CardFieldsRenderer { } disableFields() {} + enableFields() {} + + /** + * Creates and returns a new CardFields instance. + * + * @see https://developer.paypal.com/sdk/js/reference/#link-cardfields + * @param {Object} contextConfig + * @return {CardFields} + */ + createInstance( contextConfig ) { + return window.paypal.CardFields( { + createOrder: contextConfig.createOrder, + onApprove( data ) { + return contextConfig.onApprove( data ); + }, + onError( error ) { + console.error( error ); + this.spinner.unblock(); + }, + } ); + } + + /** + * Links the provided CardField instance to the local DOM. + * + * Note: If another CardField instance was inserted into the DOM before, that previous instance + * will be removed/unlinked in this process. + * + * @param {CardFields} cardFields + */ + insertAllFields( cardFields ) { + // TODO - due to delayed merge we now have similar logic in renderFields (Render.js) - review and unify logic if possible. + + this.fieldInfos.forEach( ( field ) => { + this.insertField( cardFields, field ); + } ); + + document.dispatchEvent( new CustomEvent( 'hosted_fields_loaded' ) ); + } + + /** + * Renders a single input field from the CardField-instance inside the current document's + * DOM, replacing the previous field. + * On first call, this "previous field" is the input element generated by PHP. + * + * @param {CardFields} cardField + * @param {FieldInfo} field + */ + insertField( cardField, field ) { + if ( 'function' !== typeof cardField[ field.name ] ) { + console.error( `${ field.name } is no valid CardFields property` ); + return; + } + + // Remove the previous input field from DOM + field.el?.remove(); + + // Render the CardField input element - a div containing an iframe. + cardField[ field.name ]( field.options ).render( field.wrapper ); + + // Store a reference to the new input field in our Map. + field.el = field.wrapper.querySelector( 'div[id*="paypal"]' ); + } } export default CardFieldsRenderer; diff --git a/modules/ppcp-compat/src/CompatModule.php b/modules/ppcp-compat/src/CompatModule.php index 5c53aa7dc2..5894955484 100644 --- a/modules/ppcp-compat/src/CompatModule.php +++ b/modules/ppcp-compat/src/CompatModule.php @@ -61,6 +61,7 @@ public function run( ContainerInterface $c ): void { $this->fix_page_builders(); $this->exclude_cache_plugins_js_minification( $c ); $this->set_elementor_checkout_context(); + $this->initialize_cfw_compatibility(); $is_nyp_active = $c->get( 'compat.nyp.is_supported_plugin_version_active' ); if ( $is_nyp_active ) { @@ -405,6 +406,48 @@ function( bool $do_tag_minification, string $script_tag, $file ) { ); } + /** + * Addresses issues with CheckoutWC. + * + * @return void + */ + protected function initialize_cfw_compatibility() : void { + add_action( + 'cfw_checkout_loaded_pre_head', + function () { + $this->fix_cfw_checkout_page(); + } + ); + } + + /** + * CheckoutWC. + * + * The plugin renders its own version of the checkout page, regardless of the actual content + * of the page. That custom checkout page uses the logic of a "classic checkout page"; in case + * this website uses the block-checkout, our plugin might return incorrect JS details. + * + * Compat code: Needed to ensure that our JS functions use the classic checkout workflow, + * regardless of the presence of any block-checkout logic. + * + * @return void + */ + protected function fix_cfw_checkout_page() : void { + add_filter( + 'woocommerce_paypal_payments_context', + function ( string $context ) : string { + if ( + function_exists( 'cfw_is_checkout' ) + && apply_filters( 'cfw_load_checkout_template', cfw_is_checkout() ) + ) { + $context = 'checkout'; + } + + return $context; + } + ); + } + /** * Sets up the compatibility layer for PayPal Shipping callback & WooCommerce Name Your Price plugin. *