Files
formipay/admin/assets/js/admin-product-editor.js
2025-09-15 17:44:39 +07:00

596 lines
32 KiB
JavaScript

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: `
<div class="price-input-wrapper">
<span class="price-currency">
{{ currencySymbol }}
<span v-if="required" class="required-asterisk" style="color: red; margin-left: 4px;">*</span>
</span>
<input
type="number"
:value="inputValue"
@input="onInput"
:step="stepValue"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
/>
</div>
`
});
// --- 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($('<span/>', { 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);
});