diff --git a/.DS_Store b/.DS_Store index 5b2df12..8442f15 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/admin/assets/css/admin-global.css b/admin/assets/css/admin-global.css index 1f0b207..01ae60b 100644 --- a/admin/assets/css/admin-global.css +++ b/admin/assets/css/admin-global.css @@ -47,6 +47,7 @@ li#toplevel_page_formipay a[href^="admin.php?page=formipay-settings"]:before{ } .wp-submenu-wrap li:has(a[href="admin.php?page=formipay-products"]).current ~ li:has(a[href="edit-tags.php?taxonomy=formipay-product-category&post_type=formipay-product"]), .wp-submenu-wrap li:has(a[href="admin.php?page=formipay-products"]):hover ~ li:has(a[href="edit-tags.php?taxonomy=formipay-product-category&post_type=formipay-product"]), +li:has(a[href="edit-tags.php?taxonomy=formipay-product-category&post_type=formipay-product"]):hover, .wp-submenu-wrap li:has(a[href="edit-tags.php?taxonomy=formipay-product-category&post_type=formipay-product"]).current { display: block; } \ No newline at end of file diff --git a/admin/assets/css/admin-product-editor.css b/admin/assets/css/admin-product-editor.css index 91e0982..a09e430 100644 --- a/admin/assets/css/admin-product-editor.css +++ b/admin/assets/css/admin-product-editor.css @@ -137,4 +137,12 @@ table.wpcfto-table.inner-table tbody tr:hover td { table.wpcfto-table.inner-table tr > *:is(th, td):first-child { width: 50%; text-align: left; +} + +.variation-details-content tbody tr:first-child td:nth-child(2) .price-input-wrapper .price-currency { + background-color: #AA1C1C; + color: white; +} +.variation-details-content tbody .price-input-wrapper .price-currency { + min-width: 60px; } \ No newline at end of file diff --git a/admin/assets/js/admin-product-editor.js b/admin/assets/js/admin-product-editor.js index a78ef51..2c20dfe 100644 --- a/admin/assets/js/admin-product-editor.js +++ b/admin/assets/js/admin-product-editor.js @@ -1,5 +1,7 @@ jQuery(function ($) { + console.log(product_details) + $('a[href="admin.php?page=formipay-products"]').addClass('current').closest('li').addClass('current'); function autoset_variation_name() { @@ -47,16 +49,12 @@ jQuery(function ($) { Vue.component('price-input', { // Gunakan 'value' sebagai prop, sesuai konvensi v-model Vue 2 props: { - value: [Number, String], // Diubah dari modelValue + value: [Number, String], currencySymbol: String, - currencyDecimalDigits: { - type: Number, - default: 2 - }, - disabled: { - type: Boolean, - default: false - } + currencyDecimalDigits: { type: Number, default: 2 }, + disabled: { type: Boolean, default: false }, + required: { type: Boolean, default: false }, + placeholder: { type: String, default: 'Auto' } }, data() { return { @@ -79,13 +77,26 @@ jQuery(function ($) { }, computed: { stepValue() { - return Math.pow(10, -this.currencyDecimalDigits); + const digits = this.currencyDecimalDigits || 0; + if (!digits || digits === 0) return 1; + return 1 / Math.pow(10, digits); } }, template: `
- {{ currencySymbol }} - + + {{ currencySymbol }} + * + +
` }); @@ -98,22 +109,27 @@ jQuery(function ($) { tableRows: [], deletedKeys: [], isPhysical: window.product_details?.product_is_physical || false, - pricingMethod: window.product_details?.product_pricing_method || 'auto', - manualPrices: [], productHasVariation: window.product_details?.product_has_variation || false, jsonValue: '[]', attributeRepeaterWatcher: null, - manualPricesWatcher: null, _debounceTimer: null, productType: window.product_details?.product_type || 'digital', currencyDecimalDigits: parseInt(window.product_details?.default_currency_decimal_digits) || 2, - currencySymbol: window.product_details?.default_currency_symbol || '$' + currencySymbol: window.product_details?.default_currency_symbol || '$', }; }, + computed: { + showFlatPricing() { + const mc = !!(window.product_details && window.product_details.multicurrency); + const selected = (window.product_details && window.product_details.global_selected_currencies) || {}; + const count = Object.keys(selected).length; + return !mc || count <= 1; // flat when off or only one currency selected + } + }, async mounted() { await this.initializeOrClearTable(); this.setupAttributeRepeaterSync(); - this.setupManualPricesSync(); + this.$nextTick(() => this.enforcePriceInputStates()); const typeRadios = document.querySelectorAll('input[name="product_type"]'); typeRadios.forEach(radio => { radio.addEventListener('change', e => { @@ -121,13 +137,6 @@ jQuery(function ($) { this.isPhysical = e.target.value === 'physical'; }); }); - const pricingRadios = document.querySelectorAll('input[name="product_pricing_method"]'); - pricingRadios.forEach(radio => { - radio.addEventListener('change', async e => { - this.pricingMethod = e.target.value; - await this.rebuildTable(); - }); - }); const hasVariationToggle = document.querySelector('input[name="product_has_variation"]'); if (hasVariationToggle) { hasVariationToggle.addEventListener('change', async (e) => { @@ -135,43 +144,223 @@ jQuery(function ($) { await this.initializeOrClearTable(); }); } + // Add submit validation for missing default currency price + const postForm = document.getElementById('post'); + if (postForm) { + postForm.addEventListener('submit', (ev) => { + const missing = this.findFirstMissingDefault(); + if (missing) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + const tmpl = (window.product_details + && window.product_details.variation_table + && window.product_details.variation_table.error_missing_default_price) + || 'Please fill Regular Price for default currency (%1$s) in variation "%2$s".'; + + const msg = tmpl.replace('%1$s', missing.currencyCode).replace('%2$s', missing.rowLabel); + + if (typeof window.triggerSwalEmptyRequired === 'function') { + window.triggerSwalEmptyRequired(msg); + } else if (window.Swal && typeof window.Swal.fire === 'function') { + window.Swal.fire({ icon: 'error', title: 'Required field empty', text: msg }); + } else { + alert(msg); + } + return false; + } + }, true); + } }, beforeDestroy() { - if (this.attributeRepeaterWatcher) { - this.attributeRepeaterWatcher.disconnect(); - } - if (this.manualPricesWatcher) { - this.manualPricesWatcher.disconnect(); - } + if (this.attributeRepeaterWatcher) { + this.attributeRepeaterWatcher.disconnect(); + } + if (this._attrPoller) { + clearInterval(this._attrPoller); + this._attrPoller = null; + } }, methods: { + defaultEntry(row) { + const defCode = this.getDefaultCurrencyCode(); + const defTriple = (window.product_details && window.product_details.default_currency) || ''; + const fallback = { + currency: defTriple || (defCode ? `${defCode}:::` : 'USD:::'), + regular_price: '', + sale_price: '', + currency_decimal_digits: this.findDigitsForCode(defCode) + }; + if (!row || !Array.isArray(row.prices) || row.prices.length === 0) { + return fallback; + } + const found = row.prices.find(p => String(p.currency).split(':::')[0] === defCode); + return found || row.prices[0] || fallback; + }, + enforcePriceInputStates() { + const defTriple = (window.product_details && window.product_details.default_currency) || ''; + const defCode = String(defTriple).split(':::')[0] || ''; + const REG_PLACEHOLDER = window.product_details?.variation_table?.child_placeholder_regular || 'Enter Regular Price'; + const SALE_PLACEHOLDER = window.product_details?.variation_table?.child_placeholder_sale || 'Enter Sale Price'; + const $scope = $('#product-variables-table'); + + if (!Array.isArray(this.tableRows) || this.tableRows.length === 0) { + return; + } + + // 1) Reset all number inputs to optional + "Auto" + $scope.find('input[type="number"]').each(function () { + $(this).attr('placeholder', 'Auto').removeAttr('required'); + }); + + // 2) Flat mode: set placeholders and step for default currency inputs in main row + if (this.showFlatPricing) { + $scope.find('tbody > tr').each((rowIdx, tr) => { + const row = this.tableRows[rowIdx]; + if (!row) return; + const def = this.defaultEntry(row); + if (!def) return; + const code = String(def.currency || '').split(':::')[0]; + const digits = Number(this.findDigitsForCode(code) || 0); + const step = (!digits || digits === 0) ? 1 : (1 / Math.pow(10, digits)); + const $price = $(tr).find('td[data-cell="price"] input[type="number"]'); + const $sale = $(tr).find('td[data-cell="sale"] input[type="number"]'); + if ($price.length) { + $price.attr('placeholder', REG_PLACEHOLDER).attr('step', step).removeAttr('required'); + } + if ($sale.length) { + $sale.attr('placeholder', SALE_PLACEHOLDER).attr('step', step).removeAttr('required'); + } + }); + return; // do not process inner tables in flat mode + } + + // 3) Multi-currency inner tables + const detailsRows = $scope.find('.variation-details-row .inner-table tbody'); + detailsRows.each((rowIdx, tbodyEl) => { + const vueRow = this.tableRows[rowIdx]; + if (!vueRow || !Array.isArray(vueRow.prices)) return; + const $tbody = $(tbodyEl); + const trs = $tbody.find('tr'); + trs.each((i, tr) => { + const entry = vueRow.prices[i]; + if (!entry) return; + const code = String(entry.currency).split(':::')[0]; + const digits = Number(this.findDigitsForCode(code) || 0); + const step = (!digits || digits === 0) ? 1 : (1 / Math.pow(10, digits)); + const $inputs = $(tr).find('input[type="number"]'); + $inputs.attr('step', step); + if (code === defCode) { + if ($inputs.length) { + $inputs.eq(0).removeAttr('required').attr('placeholder', REG_PLACEHOLDER); + if ($inputs.length > 1) { + $inputs.eq(1).attr('placeholder', SALE_PLACEHOLDER); + } + } + const $firstTd = $(tr).children('td').eq(0); + if ($firstTd.find('.required-asterisk').length === 0) { + $firstTd.append($('', { class: 'required-asterisk', text: ' *', css: { color: 'red', marginLeft: '4px' } })); + } + } else { + $(tr).children('td').eq(0).find('.required-asterisk').remove(); + } + }); + }); + }, + findDigitsForCode(codeOrTriple) { + // product_details.global_currencies is the 'raw' array from PHP (with decimal_digits) + const globalsRaw = (window.product_details && window.product_details.global_currencies) || []; + const target = String(codeOrTriple).split(':::')[0]; + const found = globalsRaw.find(c => String(c.currency).split(':::')[0] === target); + const dd = parseInt(found && found.decimal_digits, 10); + return Number.isFinite(dd) ? dd : 2; + }, + buildCurrencyPriceSkeleton() { + const selectedMap = (window.product_details && window.product_details.global_selected_currencies) || {}; + let entries = Object.keys(selectedMap || {}); + // If multicurrency is off or nothing selected, fall back to default currency triple + if (!entries.length) { + const defTriple = (window.product_details && window.product_details.default_currency) || ''; + if (defTriple) entries = [defTriple]; + } + return entries.map(val => { + const parts = String(val).split(':::'); + const code = parts[0] || val; + return { + currency: val, + regular_price: '', + sale_price: '', + currency_decimal_digits: this.findDigitsForCode(code) + }; + }); + }, async initializeOrClearTable() { if (!this.productHasVariation) { - this.tableRows = []; - this.updateJson(); - return; + this.tableRows = []; + this.updateJson(); + return; } - await this.syncManualPrices(); await this.loadProductVariables(); }, _ensureRowDataStructure(row) { - if (this.pricingMethod === 'manual') { - if (typeof row.prices === 'undefined') { - row.prices = JSON.parse(JSON.stringify(this.manualPrices)); - delete row.price; - delete row.sale; - } - } else { - if (typeof row.price === 'undefined') { - row.price = 0; - row.sale = 0; - delete row.prices; - } - } - if (typeof row.expanded === 'undefined') { - row.expanded = false; - } - return row; + // Ensure expanded flag + if (typeof row.expanded === 'undefined') { + row.expanded = false; + } + + // Always use multi-currency child rows + const skeleton = this.buildCurrencyPriceSkeleton(); + + const byCode = (val) => String(val).split(':::')[0]; + // Initialize or reconcile row.prices against current global selected currencies + if (!Array.isArray(row.prices)) { + row.prices = JSON.parse(JSON.stringify(skeleton)); + } else { + const globalCodes = new Set(skeleton.map(p => byCode(p.currency))); + // keep only currencies that still exist globally + row.prices = row.prices.filter(p => p && globalCodes.has(byCode(p.currency))); + // add newly-added global currencies + skeleton.forEach(skel => { + const code = byCode(skel.currency); + const exists = row.prices.some(p => byCode(p.currency) === code); + if (!exists) row.prices.push(JSON.parse(JSON.stringify(skel))); + }); + } + + // If still empty (e.g., multicurrency off), seed with skeleton + if (!Array.isArray(row.prices) || row.prices.length === 0) { + row.prices = JSON.parse(JSON.stringify(skeleton)); + } + + // Normalize digits and ensure numeric defaults + row.prices.forEach(p => { + const code = byCode(p.currency); + p.currency_decimal_digits = this.findDigitsForCode(code); + if (typeof p.regular_price === 'undefined' || p.regular_price === null) p.regular_price = ''; + if (typeof p.sale_price === 'undefined' || p.sale_price === null) p.sale_price = ''; + }); + + // Sort so default currency is always first + const defaultCode = this.getDefaultCurrencyCode(); + row.prices.sort((a, b) => { + const byCode = val => String(val).split(':::')[0]; + if (byCode(a.currency) === defaultCode) return -1; + if (byCode(b.currency) === defaultCode) return 1; + return 0; + }); + + // Remove any legacy single-currency fields if present + delete row.price; + delete row.sale; + + return row; + }, + + getDefaultCurrencyCode() { + // Try multiple sources for robustness + const triple = (window.product_details && window.product_details.default_currency) || ''; + const codeFromTriple = String(triple).split(':::')[0]; + const fallback = window.product_details && window.product_details.default_currency_code; + return codeFromTriple || fallback || ''; }, async loadProductVariables() { if (!this.productHasVariation) { @@ -239,29 +428,29 @@ jQuery(function ($) { this.tableRows = newRows; this.updateJson(); } catch (e) { - console.error("Error building from attributes:", e); + console.warn("Attributes not available; initializing empty variations."); this.tableRows = []; this.updateJson(); } }, getAttributeRepeaterData() { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { let attempts = 0; const maxAttempts = 100; const interval = setInterval(() => { - const el = $('#variation-product_variation_attributes'); + const el = $('input[name="product_variation_attributes"]'); if (el.length && el.val()) { try { const data = JSON.parse(el.val()); clearInterval(interval); resolve(Array.isArray(data) ? data : []); - } catch { + } catch (e) { clearInterval(interval); - reject(new Error('Invalid JSON in attribute repeater')); + resolve([]); } } else if (++attempts >= maxAttempts) { clearInterval(interval); - reject(new Error('Attribute repeater data not found')); + resolve([]); } }, 50); }); @@ -284,61 +473,109 @@ jQuery(function ($) { }); }, setupAttributeRepeaterSync() { - const target = document.getElementById('variation-product_variation_attributes'); - if (!target) return; - const observer = new MutationObserver(() => { + const rebuildDebounced = () => { clearTimeout(this._debounceTimer); - this._debounceTimer = setTimeout(() => this.buildFromAttributes(), 300); - }); - observer.observe(target, { - attributes: true, - attributeFilter: ['value'] - }); - this.attributeRepeaterWatcher = observer; - }, - async syncManualPrices() { - const el = document.getElementById('general-product_prices'); - if (el && el.value) { - try { - const pricesData = JSON.parse(el.value); - this.manualPrices = Array.isArray(pricesData) ? pricesData : []; - } catch (e) { - this.manualPrices = []; - } - } else { - this.manualPrices = []; + this._debounceTimer = setTimeout(() => { + this.buildFromAttributes(); + this.updateJson(); + }, 200); + }; + + // Root hidden input that contains the full JSON + const rootInput = document.querySelector('input[name="product_variation_attributes"]'); + if (rootInput) { + // Listen to direct input/change + rootInput.addEventListener('input', rebuildDebounced); + rootInput.addEventListener('change', rebuildDebounced); + + // Observe value attribute changes (WPCFTO updates .value programmatically) + const obs = new MutationObserver(rebuildDebounced); + obs.observe(rootInput, { attributes: true, attributeFilter: ['value'] }); + this.attributeRepeaterWatcher = obs; + // Fallback poller in case the repeater mutates value without firing events + this._lastAttrJson = (rootInput && rootInput.value) || ''; + if (this._attrPoller) clearInterval(this._attrPoller); + this._attrPoller = setInterval(() => { + const el = document.querySelector('input[name="product_variation_attributes"]'); + if (!el) return; + const curr = el.value || ''; + if (curr !== this._lastAttrJson) { + this._lastAttrJson = curr; + rebuildDebounced(); + } + }, 300); } + + // Also watch nested variation lists to catch intermediate states before root JSON updates + const nestedSelector = 'input[name^="product_variation_attributes_"][name$="_attribute_variations"]'; + const attachNestedObservers = () => { + document.querySelectorAll(nestedSelector).forEach((inp) => { + // Skip if already tagged + if (inp.__fpObserved) return; + inp.__fpObserved = true; + inp.addEventListener('input', rebuildDebounced); + inp.addEventListener('change', rebuildDebounced); + }); + }; + attachNestedObservers(); + + // Also watch variation title inputs for changes + const titleSelector = 'input[name^="product_variation_attributes_"][name$="_variation_label"]'; + const attachTitleObservers = () => { + document.querySelectorAll(titleSelector).forEach(inp => { + if (inp.__fpTitleObserved) return; + inp.__fpTitleObserved = true; + inp.addEventListener('input', rebuildDebounced); + inp.addEventListener('change', rebuildDebounced); + }); + }; + attachTitleObservers(); + + // Also watch variation value inputs for changes + const valueSelector = 'input[name^="product_variation_attributes_"][name$="_variation_value"]'; + const attachValueObservers = () => { + document.querySelectorAll(valueSelector).forEach(inp => { + if (inp.__fpValueObserved) return; + inp.__fpValueObserved = true; + inp.addEventListener('input', rebuildDebounced); + inp.addEventListener('change', rebuildDebounced); + }); + }; + attachValueObservers(); + + // Trigger initial build right away if attributes already present + this.buildFromAttributes(); + + // Re-attach on WPCFTO repeater events + $(document).on('repeater-item-added repeater-item-removed', (ev, repeater) => { + if (!repeater) return; + if (repeater.field_name && String(repeater.field_name).indexOf('product_variation_attributes') === 0) { + attachNestedObservers(); + attachTitleObservers(); + attachValueObservers(); + rebuildDebounced(); + } + }); + + // Ensure table rebuild when attributes repeater changes + $(document).on('change input', '.product_variation_attributes.repeater input, .product_variation_attributes.repeater select', rebuildDebounced); }, - setupManualPricesSync() { - const target = document.getElementById('general-product_prices'); - if (!target) return; - const observer = new MutationObserver(async () => { - await this.syncManualPrices(); - if (this.pricingMethod === 'manual') { - this.tableRows.forEach(row => { - const newPricesFromRepeater = this.manualPrices; - let reconciledPrices = row.prices.filter(existingPrice => - newPricesFromRepeater.some(newPrice => newPrice.currency === existingPrice.currency) - ); - newPricesFromRepeater.forEach(newPrice => { - const isAlreadyThere = reconciledPrices.some(p => p.currency === newPrice.currency); - if (!isAlreadyThere) { - reconciledPrices.push(JSON.parse(JSON.stringify(newPrice))); - } - }); - row.prices = reconciledPrices; - }); - } - this.updateJson(); - }); - observer.observe(target, { - attributes: true, - attributeFilter: ['value'] - }); - this.manualPricesWatcher = observer; + findFirstMissingDefault() { + const defCode = this.getDefaultCurrencyCode(); + for (let i = 0; i < this.tableRows.length; i++) { + const row = this.tableRows[i]; + if (!row || !Array.isArray(row.prices)) continue; + const entry = row.prices.find(p => String(p.currency).split(':::')[0] === defCode); + const reg = entry && entry.regular_price != null ? String(entry.regular_price).trim() : ''; + if (!reg) { + return { index: i, rowLabel: row.name || `Row ${i+1}`, currencyCode: defCode }; + } + } + return null; }, updateJson() { this.jsonValue = JSON.stringify(this.tableRows); + this.$nextTick(() => this.enforcePriceInputStates()); }, async rebuildTable() { await this.initializeOrClearTable(); diff --git a/admin/functions.php b/admin/functions.php index e02dd0a..e8e1a56 100644 --- a/admin/functions.php +++ b/admin/functions.php @@ -78,16 +78,20 @@ function formipay_default_currency($return='raw') { } -function formipay_global_currency_options() { +function formipay_global_currency_options($output = 'options_array') { $formipay_settings = get_option('formipay_settings'); // $currencies = (false !== boolval($formipay_settings['enable_multicurrency'])) ? formipay_default_currency() : []; $currencies = []; if(false !== boolval($formipay_settings['enable_multicurrency']) && !empty($formipay_settings['multicurrencies'])) { - foreach($formipay_settings['multicurrencies'] as $currency){ - $currency_value = $currency['currency']; - $currency_label = formipay_get_currency_data_by_value($currency_value, 'title'); - $currencies[$currency_value] = $currency_label; + if($output === 'options_array'){ + foreach($formipay_settings['multicurrencies'] as $currency){ + $currency_value = $currency['currency']; + $currency_label = formipay_get_currency_data_by_value($currency_value, 'title'); + $currencies[$currency_value] = $currency_label; + } + }elseif($output == 'raw'){ + $currencies = $formipay_settings['multicurrencies']; } } // if(empty($currencies)){ @@ -98,6 +102,49 @@ function formipay_global_currency_options() { } +function get_global_currency_array() { + $multicurrency = formipay_is_multi_currency_active(); + $global_currencies = formipay_global_currency_options('raw'); + $default_currency = formipay_default_currency(); + + $product_currency_group = []; + + $ifSingleCurrency = true; + if(boolval($multicurrency)){ + $ifSingleCurrency = false; + if(count($global_currencies) === 1){ + $ifSingleCurrency = true; + } + } + + if(false === $ifSingleCurrency){ + // $currency_sort = []; + $default_sort_key = null; + foreach($global_currencies as $key => $currency){ + $currency_value = $currency['currency']; + if($currency_value === $default_currency){ + $default_sort_key = $key; + } + } + $currency_sort = [$default_sort_key => $global_currencies[$default_sort_key]]; + unset($global_currencies[$default_sort_key]); + $global_currencies = $currency_sort + $global_currencies; + }else{ + if(false === boolval($multicurrency)){ + $global_currencies = [ + [ + 'currency' => formipay_default_currency(), + 'decimal_digits' => formipay_default_currency('decimal_digits'), + 'decimal_symbol' => formipay_default_currency('decimal_symbol'), + 'thousand_separator' => formipay_default_currency('thousand_separator'), + ] + ]; + } + } + + return $global_currencies; +} + function formipay_country_array() { $json = file_get_contents(FORMIPAY_PATH . 'admin/assets/json/country.json'); diff --git a/admin/templates/product-variations.php b/admin/templates/product-variations.php index 0a89822..449bb63 100644 --- a/admin/templates/product-variations.php +++ b/admin/templates/product-variations.php @@ -9,16 +9,10 @@ {{ product_details.variation_table.th_weight }} - - - - - + {{ product_details.variation_table.th_price }} + {{ product_details.variation_table.th_sale }} + {{ product_details.variation_table.th_prices_overall }} + @@ -44,23 +38,30 @@ - + + + + + + - - - -