jQuery(function ($) { console.log(product_details) $('a[href="admin.php?page=formipay-products"]').addClass('current').closest('li').addClass('current'); function autoset_variation_name() { var repeater_single = $('.product_variation_attributes.repeater [parent_repeater="parent"] > .wpcfto-field-content > .wpcfto-repeater-single'); $.each(repeater_single, function (key, parent) { var repeater_child = $(parent).find('[field_native_name="product_variation_attributes"]'); var attribute_name = repeater_child.find(`[name="product_variation_attributes_${key}_attribute_name"]`).val(); var attribute_type = repeater_child.find(`[name="product_variation_attributes_${key}_attribute_type"]`).val(); var repeater_child_single = repeater_child.find('.wpcfto-repeater-single'); $.each(repeater_child_single, function (index, child) { var label_field = $(`input[name="product_variation_attributes_${key}_attribute_variations_${index}_variation_label"]`); var name_field = $(`input[name="product_variation_attributes_${key}_attribute_variations_${index}_variation_name"]`); var color_field = $(`input[name="product_variation_attributes_${key}_attribute_variations_${index}_variation_color"]`); var color_field_row = color_field.closest('.wpcfto-repeater-field'); if (attribute_type == 'color') { color_field_row.show(); } else { color_field_row.hide(); } }); }); } $(document).on('change blur', '[field_native_name_inner="variation_label"] input', function () { autoset_variation_name(); }); $(document).on('click', '.stm_metaboxes_grid .stm_metaboxes_grid__inner .wpcfto-repeater .addArea', function () { autoset_variation_name(); }); var onMetaboxLoaded = setInterval(() => { var repeater_single = $('.product_variation_attributes.repeater [parent_repeater="parent"] > .wpcfto-field-content > .wpcfto-repeater-single'); if (repeater_single.length > 0) { autoset_variation_name(); clearInterval(onMetaboxLoaded); } }, 250); var waitForTable = setInterval(() => { if ($('#product-variables-table').length > 0) { clearInterval(waitForTable); // --- PERBAIKAN UTAMA DI SINI --- Vue.component('price-input', { // Gunakan 'value' sebagai prop, sesuai konvensi v-model Vue 2 props: { value: [Number, String], currencySymbol: String, currencyDecimalDigits: { type: Number, default: 2 }, disabled: { type: Boolean, default: false }, required: { type: Boolean, default: false }, placeholder: { type: String, default: 'Auto' } }, data() { return { inputValue: this.value // Diubah dari modelValue }; }, watch: { // Amati perubahan pada prop 'value' value(newValue) { // Diubah dari modelValue this.inputValue = newValue; } }, methods: { onInput(e) { const value = e.target.value; this.inputValue = value; // Pancarkan event 'input', sesuai konvensi v-model Vue 2 this.$emit('input', value); // Diubah dari 'update:modelValue' } }, computed: { stepValue() { const digits = this.currencyDecimalDigits || 0; if (!digits || digits === 0) return 1; return 1 / Math.pow(10, digits); } }, template: `
{{ currencySymbol }} *
` }); // --- AKHIR DARI PERBAIKAN --- new Vue({ el: '#product-variables-table', data() { return { tableRows: [], deletedKeys: [], isPhysical: window.product_details?.product_is_physical || false, productHasVariation: window.product_details?.product_has_variation || false, jsonValue: '[]', attributeRepeaterWatcher: 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 || '$', }; }, 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.$nextTick(() => this.enforcePriceInputStates()); const typeRadios = document.querySelectorAll('input[name="product_type"]'); typeRadios.forEach(radio => { radio.addEventListener('change', e => { this.productType = e.target.value; this.isPhysical = e.target.value === 'physical'; }); }); const hasVariationToggle = document.querySelector('input[name="product_has_variation"]'); if (hasVariationToggle) { hasVariationToggle.addEventListener('change', async (e) => { this.productHasVariation = e.target.checked; 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._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; } await this.loadProductVariables(); }, _ensureRowDataStructure(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) { this.tableRows = []; this.updateJson(); return; } const postId = window.formipayProductId || $('input[name="post_ID"]').val(); if (!postId) { await this.buildFromAttributes(); return; } try { const res = await $.ajax({ url: ajaxurl, method: 'POST', data: { action: 'get_product_variables', post_id: postId } }); if (res.success && Array.isArray(res.data) && res.data.length) { this.tableRows = res.data.map(row => this._ensureRowDataStructure(row)); this.deletedKeys = []; this.updateJson(); } else { await this.buildFromAttributes(); } } catch { await this.buildFromAttributes(); } }, async buildFromAttributes() { if (!this.productHasVariation) { this.tableRows = []; this.updateJson(); return; } try { const attributes = await this.getAttributeRepeaterData(); if (!attributes.length) { this.tableRows = []; this.updateJson(); return; } const combinations = this.getAllCombinations(attributes); const filtered = combinations.filter(c => !this.deletedKeys.includes(c.key)); const newRows = filtered.map(c => { const existing = this.tableRows.find(r => r.key === c.key); if (existing) { this._ensureRowDataStructure(existing); return Object.assign(existing, { name: c.label }); } let newRowData = { key: c.key, name: c.label, stock: '', weight: 0, active: true, }; return this._ensureRowDataStructure(newRowData); }); this.tableRows = newRows; this.updateJson(); } catch (e) { console.warn("Attributes not available; initializing empty variations."); this.tableRows = []; this.updateJson(); } }, getAttributeRepeaterData() { return new Promise((resolve) => { let attempts = 0; const maxAttempts = 100; const interval = setInterval(() => { 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 (e) { clearInterval(interval); resolve([]); } } else if (++attempts >= maxAttempts) { clearInterval(interval); resolve([]); } }, 50); }); }, getAllCombinations(attributes) { const attrVars = attributes .map(attr => (attr.attribute_variations || []).map(v => ({ label: v.variation_label }))) .filter(arr => arr.length > 0); if (!attrVars.length) return []; const combine = (arrs) => arrs.reduce((a, b) => a.flatMap(d => b.map(e => [].concat(d, e)))); const combos = combine(attrVars); return combos.map(combo => { const labels = Array.isArray(combo) ? combo.map(c => c.label) : [combo.label]; return { key: labels.join('||'), label: labels.join(' - ') }; }); }, setupAttributeRepeaterSync() { const rebuildDebounced = () => { clearTimeout(this._debounceTimer); 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); }, 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(); } }, watch: { tableRows: { handler() { this.updateJson(); }, deep: true } } }); } }, 250); });