diff --git a/.eslintrc.json b/.eslintrc.json index 819db43..a241338 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -8,7 +8,11 @@ }, "globals": { "ms3": "readonly", - "ms3Config": "readonly" + "ms3Config": "readonly", + "getSelectors": "readonly" }, - "ignorePatterns": ["**/node_modules/**", "**/mgr/**"] + "ignorePatterns": ["**/node_modules/**", "**/mgr/**"], + "rules": { + "no-unused-vars": ["error", { "varsIgnorePattern": "^(CartUI|CustomerUI|OrderUI|ProductCardUI|QuantityUI)$" }] + } } diff --git a/_build/elements/settings.php b/_build/elements/settings.php index 195d602..e7a50e4 100644 --- a/_build/elements/settings.php +++ b/_build/elements/settings.php @@ -233,6 +233,7 @@ "[[+jsUrl]]web\/lib\/izitoast\/iziToast.js", "[[+jsUrl]]web\/modules\/hooks.js", "[[+jsUrl]]web\/modules\/message.js", + "[[+jsUrl]]web\/core\/Selectors.js", "[[+jsUrl]]web\/core\/ApiClient.js", "[[+jsUrl]]web\/core\/TokenManager.js", "[[+jsUrl]]web\/core\/CartAPI.js", diff --git a/assets/components/minishop3/js/web/core/Selectors.js b/assets/components/minishop3/js/web/core/Selectors.js new file mode 100644 index 0000000..adde879 --- /dev/null +++ b/assets/components/minishop3/js/web/core/Selectors.js @@ -0,0 +1,36 @@ +/** + * Default selectors for MiniShop3 UI + * Overridable via ms3Config.selectors + * Priority: data-* attributes, fallback: CSS classes (backward compatibility) + * + * @see https://github.com/modx-pro/MiniShop3/issues/18 + */ +const defaultSelectors = { + form: '[data-ms3-form], .ms3_form', + formOrder: '[data-ms3-form="order"], .ms3_order_form', + formCustomer: '[data-ms3-form="customer"], .ms3_customer_form', + cartOptions: '[data-ms3-cart-options], .ms3_cart_options', + qtyInput: '[data-ms3-qty="input"], .qty-input', + qtyInc: '[data-ms3-qty="inc"], .inc-qty', + qtyDec: '[data-ms3-qty="dec"], .dec-qty', + productCard: '[data-ms3-product-card], .ms3-product-card', + fieldError: '[data-ms3-error], .ms3_field_error', + orderCost: '#ms3_order_cost', + orderCartCost: '#ms3_order_cart_cost', + orderDeliveryCost: '#ms3_order_delivery_cost', + link: '.ms3_link' +} + +/** + * Get merged selectors (defaults + ms3Config.selectors overrides) + * @returns {Object} Selectors object + */ +function getSelectors () { + const custom = (typeof window !== 'undefined' && window.ms3Config && window.ms3Config.selectors) || {} + return { ...defaultSelectors, ...custom } +} + +// Expose for fallback when Selectors.js loads but getSelectors fails (e.g. in unit tests) +if (typeof window !== 'undefined') { + window.Ms3DefaultSelectors = defaultSelectors +} diff --git a/assets/components/minishop3/js/web/ms3.js b/assets/components/minishop3/js/web/ms3.js index 3899d0d..423614a 100644 --- a/assets/components/minishop3/js/web/ms3.js +++ b/assets/components/minishop3/js/web/ms3.js @@ -22,10 +22,14 @@ * - data-ms3-qty="input"|"inc"|"dec" — quantity control * - data-ms3-cart-options — cart options select */ -/* global TokenManager, ApiClient, CartAPI, OrderAPI, CustomerAPI, CartUI, OrderUI, CustomerUI, ProductCardUI */ +/* global TokenManager, ApiClient, CartAPI, OrderAPI, CustomerAPI, CartUI, OrderUI, CustomerUI, QuantityUI, ProductCardUI, getSelectors */ const ms3 = { config: {}, + get selectors () { + return this.config?.selectors || {} + }, + tokenManager: null, apiClient: null, @@ -48,6 +52,27 @@ const ms3 = { async init () { this.config = window.ms3Config || {} + // Merge selectors from Selectors.js (overridable via ms3Config.selectors) + const rawSelectors = typeof getSelectors === 'function' + ? getSelectors() + : (window.Ms3DefaultSelectors || {}) + const selectorDefaults = window.Ms3DefaultSelectors || { + form: '[data-ms3-form], .ms3_form', + formOrder: '[data-ms3-form="order"], .ms3_order_form', + formCustomer: '[data-ms3-form="customer"], .ms3_customer_form', + cartOptions: '[data-ms3-cart-options], .ms3_cart_options', + qtyInput: '[data-ms3-qty="input"], .qty-input', + qtyInc: '[data-ms3-qty="inc"], .inc-qty', + qtyDec: '[data-ms3-qty="dec"], .dec-qty', + productCard: '[data-ms3-product-card], .ms3-product-card', + fieldError: '[data-ms3-error], .ms3_field_error', + orderCost: '#ms3_order_cost', + orderCartCost: '#ms3_order_cart_cost', + orderDeliveryCost: '#ms3_order_delivery_cost', + link: '.ms3_link' + } + this.config = { ...this.config, selectors: { ...selectorDefaults, ...rawSelectors } } + this.hooks = window.ms3Hooks || this.createFallbackHooks() this.message = window.ms3Message || this.createFallbackMessage() @@ -95,7 +120,7 @@ const ms3 = { }, /** - * [data-ms3-form] submit handler (fallback: .ms3_form deprecated) + * Form submit handler (uses sel.form from config) * * Automatically calls appropriate API method based on ms3_action: * - cart/add → cartUI.handleAdd() @@ -103,9 +128,11 @@ const ms3 = { * - etc. */ initFormHandler () { + const formSelector = this.selectors.form + document.addEventListener('submit', async (event) => { const form = event.target - if (!form.hasAttribute('data-ms3-form') && !form.classList.contains('ms3_form')) { + if (!form.matches(formSelector)) { return } @@ -126,21 +153,24 @@ const ms3 = { }, /** - * .ms3_link click handler + * Link click handler (uses sel.link, sel.form from config) * - * Handles clicks on buttons/links with .ms3_link class - * inside .ms3_form forms. Triggers form submit. + * Handles clicks on buttons/links with sel.link + * inside forms matching sel.form. Triggers form submit. */ initLinkHandler () { + const linkSelector = this.selectors.link + const formSelector = this.selectors.form + document.addEventListener('click', async (event) => { - const link = event.target.closest('.ms3_link') + const link = event.target.closest(linkSelector) if (!link) { return } - const form = link.closest('[data-ms3-form]') || link.closest('.ms3_form') + const form = link.closest(formSelector) if (!form) { - console.warn('.ms3_link must be inside a form with data-ms3-form or .ms3_form') + console.warn(`ms3_link must be inside a form matching: ${formSelector}`) return } diff --git a/assets/components/minishop3/js/web/ui/CartUI.js b/assets/components/minishop3/js/web/ui/CartUI.js index 67b6812..acacffe 100644 --- a/assets/components/minishop3/js/web/ui/CartUI.js +++ b/assets/components/minishop3/js/web/ui/CartUI.js @@ -32,6 +32,10 @@ class CartUI { this.config = config } + get selectors () { + return this.config?.selectors || {} + } + /** * Initialize UI handlers */ @@ -40,22 +44,14 @@ class CartUI { } /** - * Product option selects: [data-ms3-cart-options] or .ms3_cart_options (fallback) + * Product option selects: uses sel.cartOptions from config */ initOptionSelects () { - const byData = document.querySelectorAll('[data-ms3-cart-options]') - const byClass = document.querySelectorAll('.ms3_cart_options') - const seen = new Set() - const selects = [] - ;[...byData, ...byClass].forEach(select => { - if (!seen.has(select)) { - seen.add(select) - selects.push(select) - } - }) - selects.forEach(select => { + const { cartOptions: cartOptionsSelector, form: formSelector } = this.selectors + + document.querySelectorAll(cartOptionsSelector).forEach(select => { select.addEventListener('change', async (e) => { - const form = e.target.closest('[data-ms3-form]') || e.target.closest('.ms3_form') + const form = e.target.closest(formSelector) if (!form) return console.log('Option changed:', e.target.name, e.target.value) diff --git a/assets/components/minishop3/js/web/ui/CustomerUI.js b/assets/components/minishop3/js/web/ui/CustomerUI.js index 57f23ee..420324e 100644 --- a/assets/components/minishop3/js/web/ui/CustomerUI.js +++ b/assets/components/minishop3/js/web/ui/CustomerUI.js @@ -31,6 +31,10 @@ class CustomerUI { this.config = config } + get selectors () { + return this.config?.selectors || {} + } + /** * Get lexicon string (window.ms3Lexicon, then fallback, then key) * @param {string} key - Lexicon key @@ -47,7 +51,7 @@ class CustomerUI { * Initialize UI handlers */ init () { - document.querySelectorAll('[data-ms3-form="customer"], .ms3_customer_form').forEach(form => { + document.querySelectorAll(this.selectors.formCustomer).forEach(form => { this.initForm(form) }) } @@ -72,7 +76,7 @@ class CustomerUI { */ initInput (input) { input.addEventListener('change', async () => { - const form = input.closest('[data-ms3-form="customer"], .ms3_customer_form') + const form = input.closest(this.selectors.formCustomer) if (!form) return const parent = input.closest('div') diff --git a/assets/components/minishop3/js/web/ui/OrderUI.js b/assets/components/minishop3/js/web/ui/OrderUI.js index 6e9678d..03dc4da 100644 --- a/assets/components/minishop3/js/web/ui/OrderUI.js +++ b/assets/components/minishop3/js/web/ui/OrderUI.js @@ -17,11 +17,15 @@ class OrderUI { this.config = config } + get selectors () { + return this.config?.selectors || {} + } + /** * Initialize UI handlers */ init () { - document.querySelectorAll('[data-ms3-form="order"], .ms3_order_form').forEach(form => { + document.querySelectorAll(this.selectors.formOrder).forEach(form => { this.initForm(form) }) @@ -157,23 +161,22 @@ class OrderUI { if (response.success && response.data) { const { cost, cart_cost: cartCost, delivery_cost: deliveryCost } = response.data - // Update cart cost - const cartCostEl = document.getElementById('ms3_order_cart_cost') - if (cartCostEl && cartCost !== undefined) { - cartCostEl.textContent = this.formatPrice(cartCost) + const cartCostElement = document.querySelector(this.selectors.orderCartCost) + if (cartCostElement && cartCost !== undefined) { + cartCostElement.textContent = this.formatPrice(cartCost) } // Update delivery cost - const deliveryCostEl = document.getElementById('ms3_order_delivery_cost') - if (deliveryCostEl && deliveryCost !== undefined) { - deliveryCostEl.textContent = this.formatPrice(deliveryCost) + const deliveryCostElement = document.querySelector(this.selectors.orderDeliveryCost) + if (deliveryCostElement && deliveryCost !== undefined) { + deliveryCostElement.textContent = this.formatPrice(deliveryCost) } // Update total cost - const totalCostEl = document.getElementById('ms3_order_cost') - if (totalCostEl && cost !== undefined) { - totalCostEl.textContent = this.formatPrice(cost) + const totalCostElement = document.querySelector(this.selectors.orderCost) + if (totalCostElement && cost !== undefined) { + totalCostElement.textContent = this.formatPrice(cost) } await this.hooks.runHooks('afterUpdateOrderCosts', { cost, cart_cost: cartCost, delivery_cost: deliveryCost }) @@ -264,7 +267,7 @@ class OrderUI { } if (response.success) { - document.querySelectorAll('[data-ms3-form="order"], .ms3_order_form').forEach(form => { + document.querySelectorAll(this.selectors.formOrder).forEach(form => { form.reset() }) document.dispatchEvent(new CustomEvent('ms3:cart:updated')) @@ -283,19 +286,19 @@ class OrderUI { * @param {Array} errors - Array of field names with errors */ highlightErrors (errors) { - document.querySelectorAll('[data-ms3-error], .ms3_field_error').forEach(el => { - el.removeAttribute('data-ms3-error') - el.classList.remove('ms3_field_error') + document.querySelectorAll(this.selectors.fieldError).forEach(element => { + element.removeAttribute('data-ms3-error') + element.classList.remove('ms3_field_error') }) errors.forEach(fieldName => { - const selectors = [ + const selectorList = [ `[name="${fieldName}"]`, `[name="address_${fieldName}"]`, `[name="order_${fieldName}"]` ] - selectors.forEach(selector => { + selectorList.forEach(selector => { const field = document.querySelector(selector) if (field) { field.setAttribute('data-ms3-error', '') diff --git a/assets/components/minishop3/js/web/ui/ProductCardUI.js b/assets/components/minishop3/js/web/ui/ProductCardUI.js index c248167..7c3c573 100644 --- a/assets/components/minishop3/js/web/ui/ProductCardUI.js +++ b/assets/components/minishop3/js/web/ui/ProductCardUI.js @@ -33,6 +33,10 @@ class ProductCardUI { this.cartState = {} } + get selectors () { + return this.config?.selectors || {} + } + /** * Initialize product card UI */ @@ -127,7 +131,7 @@ class ProductCardUI { * Update all product cards on page */ updateAllCards () { - const cards = document.querySelectorAll('[data-ms3-product-card], .ms3-product-card') + const cards = document.querySelectorAll(this.selectors.productCard) cards.forEach(card => { this.updateCard(card) @@ -161,7 +165,7 @@ class ProductCardUI { changeForm.style.display = 'flex' // Update quantity input - const countInput = changeForm.querySelector('[data-ms3-qty="input"]') || changeForm.querySelector('.qty-input') + const countInput = changeForm.querySelector(this.selectors.qtyInput) if (countInput) { countInput.value = this.cartState[productId].totalCount } diff --git a/assets/components/minishop3/js/web/ui/QuantityUI.js b/assets/components/minishop3/js/web/ui/QuantityUI.js index dddd72f..fd86529 100644 --- a/assets/components/minishop3/js/web/ui/QuantityUI.js +++ b/assets/components/minishop3/js/web/ui/QuantityUI.js @@ -32,6 +32,10 @@ class QuantityUI { this.config = config } + get selectors () { + return this.config?.selectors || {} + } + /** * Initialize quantity controls */ @@ -49,19 +53,12 @@ class QuantityUI { } /** - * Initialize quantity buttons: [data-ms3-qty="inc"]/[data-ms3-qty="dec"] or .inc-qty/.dec-qty (fallback) + * Initialize quantity buttons: uses sel.qtyInc, sel.qtyDec from config */ initButtons () { - const byData = document.querySelectorAll('[data-ms3-qty="inc"], [data-ms3-qty="dec"]') - const byClass = document.querySelectorAll('.inc-qty, .dec-qty') - const seen = new Set() - const buttons = [] - ;[...byData, ...byClass].forEach(btn => { - if (!seen.has(btn)) { - seen.add(btn) - buttons.push(btn) - } - }) + const { qtyInc: quantityIncreaseSelector, qtyDec: quantityDecreaseSelector } = this.selectors + const buttons = document.querySelectorAll([quantityIncreaseSelector, quantityDecreaseSelector].join(', ')) + buttons.forEach(btn => { const newBtn = btn.cloneNode(true) btn.parentNode.replaceChild(newBtn, btn) @@ -70,19 +67,12 @@ class QuantityUI { } /** - * Initialize quantity inputs: [data-ms3-qty="input"] or .qty-input (fallback) + * Initialize quantity inputs: uses sel.qtyInput from config */ initInputs () { - const byData = document.querySelectorAll('[data-ms3-qty="input"]') - const byClass = document.querySelectorAll('.qty-input') - const seen = new Set() - const inputs = [] - ;[...byData, ...byClass].forEach(input => { - if (!seen.has(input)) { - seen.add(input) - inputs.push(input) - } - }) + const quantityInputSelector = this.selectors.qtyInput + const inputs = document.querySelectorAll(quantityInputSelector) + inputs.forEach(input => { const newInput = input.cloneNode(true) input.parentNode.replaceChild(newInput, input) @@ -98,16 +88,18 @@ class QuantityUI { async handleButtonClick (e) { e.preventDefault() - const form = e.target.closest('[data-ms3-form]') || e.target.closest('.ms3_form') + const { form: formSelector, qtyInput: quantityInputSelector, qtyInc: quantityIncreaseSelector, qtyDec: quantityDecreaseSelector } = this.selectors + + const form = e.target.closest(formSelector) if (!form) return - const input = form.querySelector('[data-ms3-qty="input"]') || form.querySelector('.qty-input') + const input = form.querySelector(quantityInputSelector) if (!input) return let qty = parseInt(input.value) || 0 - const isInc = e.target.getAttribute('data-ms3-qty') === 'inc' || e.target.classList.contains('inc-qty') - const isDec = e.target.getAttribute('data-ms3-qty') === 'dec' || e.target.classList.contains('dec-qty') + const isInc = e.target.matches(quantityIncreaseSelector) + const isDec = e.target.matches(quantityDecreaseSelector) if (isInc) qty++ if (isDec) qty-- @@ -124,7 +116,7 @@ class QuantityUI { * @param {Event} e - Change event */ async handleInputChange (e) { - const form = e.target.closest('[data-ms3-form]') || e.target.closest('.ms3_form') + const form = e.target.closest(this.selectors.form) if (!form) return const qty = Math.max(0, parseInt(e.target.value) || 0) diff --git a/core/components/minishop3/migrations/20260217120000_add_selectors_to_frontend_assets.php b/core/components/minishop3/migrations/20260217120000_add_selectors_to_frontend_assets.php new file mode 100644 index 0000000..3cc3063 --- /dev/null +++ b/core/components/minishop3/migrations/20260217120000_add_selectors_to_frontend_assets.php @@ -0,0 +1,102 @@ +getAdapter()->getOption('table_prefix'); + $table = $prefix . 'system_settings'; + + $row = $this->fetchRow( + "SELECT `value` FROM `{$table}` WHERE `key` = 'ms3_frontend_assets'" + ); + + if (!$row) { + return; + } + + $assets = json_decode($row['value'], true); + if (!is_array($assets)) { + return; + } + + $normalizedAssets = array_map(function ($path) { + return str_replace('\\/', '/', $path); + }, $assets); + + $newFileNormalized = str_replace('\\/', '/', self::NEW_FILE); + $insertAfterNormalized = str_replace('\\/', '/', self::INSERT_AFTER); + + if (in_array($newFileNormalized, $normalizedAssets, true)) { + return; + } + + $insertPosition = array_search($insertAfterNormalized, $normalizedAssets, true); + + if ($insertPosition !== false) { + array_splice($assets, $insertPosition + 1, 0, [self::NEW_FILE]); + } else { + $assets[] = self::NEW_FILE; + } + + $newValue = json_encode($assets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + + $this->execute( + "UPDATE `{$table}` SET `value` = " . $this->getAdapter()->getConnection()->quote($newValue) . + " WHERE `key` = 'ms3_frontend_assets'" + ); + } + + public function down(): void + { + $prefix = $this->getAdapter()->getOption('table_prefix'); + $table = $prefix . 'system_settings'; + + $row = $this->fetchRow( + "SELECT `value` FROM `{$table}` WHERE `key` = 'ms3_frontend_assets'" + ); + + if (!$row) { + return; + } + + $assets = json_decode($row['value'], true); + if (!is_array($assets)) { + return; + } + + $assets = array_values(array_filter($assets, function ($path) { + $normalized = str_replace('\\/', '/', $path); + return $normalized !== str_replace('\\/', '/', self::NEW_FILE); + })); + + $newValue = json_encode($assets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + + $this->execute( + "UPDATE `{$table}` SET `value` = " . $this->getAdapter()->getConnection()->quote($newValue) . + " WHERE `key` = 'ms3_frontend_assets'" + ); + } +} diff --git a/core/components/minishop3/src/MiniShop3.php b/core/components/minishop3/src/MiniShop3.php index 7f15bce..980301b 100644 --- a/core/components/minishop3/src/MiniShop3.php +++ b/core/components/minishop3/src/MiniShop3.php @@ -158,7 +158,8 @@ public function registerFrontend($ctx = 'web') 'tokenName' => $tokenName, 'render' => [ 'cart' => [] - ] + ], + 'selectors' => (object)[] ]; $data = json_encode($js_setting, JSON_UNESCAPED_UNICODE);