From 9342086f337e5b709de5710eb61e68969a5403b1 Mon Sep 17 00:00:00 2001 From: Philipp Stracker Date: Tue, 4 Jun 2024 22:10:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20Google=20Pay=20?= =?UTF-8?q?preview=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mainly, separate the preview code into three main parts: - Configuration - Button - ButtonManager Remove dependency from DOM structure --- modules/ppcp-googlepay/package.json | 3 +- .../ppcp-googlepay/resources/js/boot-admin.js | 327 +++++++++++++++++- modules/ppcp-googlepay/yarn.lock | 5 + 3 files changed, 329 insertions(+), 6 deletions(-) diff --git a/modules/ppcp-googlepay/package.json b/modules/ppcp-googlepay/package.json index c5b05bade..362598490 100644 --- a/modules/ppcp-googlepay/package.json +++ b/modules/ppcp-googlepay/package.json @@ -11,7 +11,8 @@ ], "dependencies": { "@paypal/paypal-js": "^6.0.0", - "core-js": "^3.25.0" + "core-js": "^3.25.0", + "deepmerge": "^4.3.1" }, "devDependencies": { "@babel/core": "^7.19", diff --git a/modules/ppcp-googlepay/resources/js/boot-admin.js b/modules/ppcp-googlepay/resources/js/boot-admin.js index bb5457ec8..e45cbfaf1 100644 --- a/modules/ppcp-googlepay/resources/js/boot-admin.js +++ b/modules/ppcp-googlepay/resources/js/boot-admin.js @@ -1,7 +1,327 @@ import {loadCustomScript} from "@paypal/paypal-js"; import GooglepayButton from "./GooglepayButton"; +import merge from "deepmerge"; import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/WidgetBuilder"; +/** + * Button manager instance; we usually only need a single instance of this object. + * + * @see buttonManager() + */ +let managerInstance = null; + +/** + * Default attributes for new buttons. + */ +const defaultAttributes = { + button: { + style: { + type: 'pay', + color: 'black', + language: 'en' + } + } +}; + +/** + * Accessor that creates and returns a single PreviewButtonManager instance. + */ +const buttonManager = () => { + if (!managerInstance) { + managerInstance = new PreviewButtonManager({ + // Internal, for logging. + name: 'GooglePay', + // WooCommerce configuration object. + buttonConfig: window.wc_ppcp_googlepay_admin, + // Internal widgetBuilder instance. + widgetBuilder, + // Default button styles. + defaultAttributes, + // Returns an async function that fetches the button configuration. + cbFetchConfig: () => widgetBuilder?.paypal?.Googlepay()?.config, + // Returns the CSS selector to render the given button. + cbInitButton: (button) => { + button.selector = `${button.wrapper}GooglePay` + button.customClasses = 'ppcp-button-googlepay' + } + }); + } + + return managerInstance; +} + + +// ---------------------------------------------------------------------------- + + +/** + * Returns a new object which contains a copy of all public properties of the + * input object. + */ +const cloneObject = obj => JSON.parse(JSON.stringify(obj)); + +/** + * Convenience function to attach an event handler to the document node. + * The callback handler will not receive the first argument (event), as this + * argument is not used by our module. + */ +const onDocumentEvent = (name, handler) => jQuery(document).on(name, (ev, ...args) => handler(...args)); + + +// ---------------------------------------------------------------------------- + + +/** + * A single GooglePay preview button instance. + */ +class PreviewButton { + constructor({wrapperSelector, configResponse, defaultAttributes, cbInitButton}) { + // Do not clone this object. It's generated by an API and might contain methods. + this.configResponse = configResponse; + + this.defaultAttributes = cloneObject(defaultAttributes); + this.buttonConfig = {}; + this.ppcpConfig = {}; + + this.wrapper = wrapperSelector; + this.selector = wrapperSelector + 'Button'; + this.customClasses = ''; + + cbInitButton(this); + + this.domWrapper = jQuery(this.selector); + this.payButton = null; + } + + createNewWrapper() { + const previewId = this.selector.replace('#', '') + const previewClass = `ppcp-button-apm ${this.customClasses}`; + + return jQuery(`
`) + } + + config(buttonConfig, ppcpConfig) { + if (ppcpConfig) { + this.ppcpConfig = cloneObject(ppcpConfig); + } + + if (buttonConfig) { + this.buttonConfig = merge(this.defaultAttributes, buttonConfig) + this.buttonConfig.button.wrapper = this.selector + } + + return this; + } + + render() { + this.remove(); + + const newDomWrapper = this.createNewWrapper(); + + if (this.domWrapper?.length) { + this.domWrapper.replaceWith(newDomWrapper); + } else { + jQuery(this.ppcpConfig.button.wrapper).after(newDomWrapper); + } + this.domWrapper = newDomWrapper; + + this.payButton = new GooglepayButton( + 'preview', + null, + this.buttonConfig, + this.ppcpConfig, + ); + + this.payButton.init(this.configResponse); + return this; + } + + remove() { + // The payButton has no remove/cleanup function. + this.payButton = null; + + if (this.domWrapper) { + this.domWrapper.remove(); + this.domWrapper = null; + } + } +} + +// ---------------------------------------------------------------------------- + +/** + * Manages all GooglePay preview buttons on this page. + */ +class PreviewButtonManager { + constructor({ + name, + buttonConfig, + widgetBuilder, + defaultAttributes, + cbFetchConfig, + cbInitButton + }) { + this.name = name; + this.buttonConfig = buttonConfig; + this.widgetBuilder = widgetBuilder; + this.defaultAttributes = defaultAttributes; + this.cbInitButton = cbInitButton; + this.cbFetchConfig = cbFetchConfig; + + this.state = 'enabled' + this.buttons = {}; + this.configResponse = null; + + // Empty promise that resolves instantly when called. + this.bootstrapping = Promise.resolve(); + + // Add the bootstrap logic to the Promise chain. More `then`s are added by createButton(). + this.bootstrapping = this.bootstrapping.then(() => this.bootstrap()); + + this.registerEventListeners(); + } + + registerEventListeners() { + onDocumentEvent('ppcp_paypal_render_preview', this.createButton.bind(this)); + onDocumentEvent('DOMContentLoaded', () => this.bootstrapping); + } + + /** + * Output an error message to the console, with a module-specific prefix. + */ + error(message, ...args) { + console.error(`${this.name} ${message}`, ...args) + } + + /** + * Load dependencies and bootstrap the module. + * Returns a Promise that resolves once all dependencies were loaded and the module can be + * used without limitation. + * + * @return {Promise} + */ + async bootstrap() { + if (!this.buttonConfig || !this.widgetBuilder) { + this.error('Button could not be configured.'); + return; + } + + // Load the custom SDK script. + const customScriptPromise = loadCustomScript({url: this.buttonConfig.sdk_url}); + + // Wait until PayPal is ready. + const paypalPromise = new Promise(resolve => { + if (this.widgetBuilder.paypal) { + resolve(); + } else { + onDocumentEvent('ppcp-paypal-loaded', resolve); + } + }); + + await Promise.all([customScriptPromise, paypalPromise]); + + const fetchConfig = this.cbFetchConfig() + if (!fetchConfig) { + this.error('Button could not be initialized'); + return; + } + + this.configResponse = await fetchConfig(); + } + + /** + * Creates a new preview button, that is rendered once the bootstrapping Promise resolves. + */ + createButton(ppcpConfig) { + if (!ppcpConfig.button.wrapper) { + this.error('Button did not provide a selector', ppcpConfig) + return; + } + + const wrapper = ppcpConfig.button.wrapper; + + const createButtonInst = () => { + if (!this.buttons[wrapper]) { + this.buttons[wrapper] = new PreviewButton({ + wrapperSelector: wrapper, + configResponse: this.configResponse, + defaultAttributes: this.defaultAttributes, + cbInitButton: this.cbInitButton + }); + } + + this.buttons[wrapper].config( + this.buttonConfig, + ppcpConfig + ).render() + } + + if (this.bootstrapping) { + this.bootstrapping.then(createButtonInst); + } else { + createButtonInst(); + } + } + + /** + * Changes the button configuration, and re-renders all buttons. + */ + updateConfig(newConfig) { + if (!newConfig || 'object' !== typeof newConfig) { + return; + } + + this.buttonConfig = merge(this.buttonConfig, newConfig) + + Object.values(this.buttons).forEach(button => button.config(this.buttonConfig)) + this.renderButtons() + } + + + /** + * Refreshes all buttons using the latest buttonConfig. + */ + renderButtons() { + if ('enabled' === this.state) { + Object.values(this.buttons).forEach(button => button.render()) + } else { + Object.values(this.buttons).forEach(button => button.remove()) + } + } + + /** + * Enables this payment method, which re-creates or refreshes all buttons. + */ + enable() { + if ('enabled' === this.state) { + return; + } + this.state = 'enabled'; + this.renderButtons() + } + + /** + * Disables this payment method, effectively removing all preview buttons. + */ + disable() { + if ('disabled' === this.state) { + return; + } + + this.state = 'disabled'; + this.renderButtons() + } +} + +// Initialize the preview button manager. +buttonManager().enable() + +// todo - Expose button manager for testing. Remove this! +window.gpay = buttonManager() + + +/** + (function ({ buttonConfig, jQuery @@ -47,11 +367,6 @@ import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/Wi }, 100); }); - /** - * Decides, whether to display the Google Pay preview button. - * - * @return {boolean} - */ const shouldDisplayPreviewButton = function() { // TODO - original condition, which is wrong. return jQuery('#ppcp-googlepay_button_enabled').is(':checked'); @@ -154,3 +469,5 @@ import widgetBuilder from "../../../ppcp-button/resources/js/modules/Renderer/Wi buttonConfig: window.wc_ppcp_googlepay_admin, jQuery: window.jQuery }); + +// */ diff --git a/modules/ppcp-googlepay/yarn.lock b/modules/ppcp-googlepay/yarn.lock index ec5854d6c..c239214a9 100644 --- a/modules/ppcp-googlepay/yarn.lock +++ b/modules/ppcp-googlepay/yarn.lock @@ -1453,6 +1453,11 @@ debug@^4.1.0, debug@^4.1.1: dependencies: ms "2.1.2" +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + electron-to-chromium@^1.4.477: version "1.4.496" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.496.tgz#a57534b70d2bdee7e1ad7dbd4c91e560cbd08db1"