feat: migrate shipping to form-level and integrate flags.json as single source of truth

Shipping Migration:
- Move shipping configuration from product-level to form-level
- Add form shipping tab in form settings (no_shipping, flat_rate, free_shipping)
- Update FlatRate to register at form level instead of product level
- Update checkout logic to read from form settings
- Support percentage-based flat rate calculation
- Simplify shipping method IDs (flat_rate, free_shipping)

Currency Flags Integration:
- Add formipay_get_all_currency_flags() to read from admin/assets/json/flags.json
- Remove hardcoded CURRENCY_FLAGS emoji map from VariationField.js
- Create CurrencyFlag component to render base64 flag images
- Localize currency_flags to window.formipayProductDetails
- Update shipping info display in admin order details

Benefits:
- Form-level shipping prevents multiplying shipping costs per product
- Single source of truth for currency flags (flags.json)
- Better support for future cart system
- Consistent with e-commerce standards
This commit is contained in:
dwindown
2026-04-23 08:12:40 +07:00
parent 0094a3571c
commit 008188b790
13 changed files with 2819 additions and 797 deletions

View File

@@ -0,0 +1,346 @@
/**
* Formipay Checkout Shipping Integration
* Handles country selection, shipping method display, and cost calculation
*/
(function($) {
'use strict';
const FormipayCheckoutShipping = {
selectedCountry: '',
selectedMethod: '',
availableMethods: [],
formId: null,
init() {
this.formId = $('form[data-form-id]').data('form-id');
if (!this.formId) {
return;
}
this.bindEvents();
this.initializeCountrySelector();
},
bindEvents() {
// Country selection change
$(document).on('change', '.formipay-shipping-country', (e) => {
this.onCountryChange($(e.currentTarget).val());
});
// Shipping method selection change
$(document).on('change', '.formipay-shipping-method', (e) => {
this.onShippingMethodChange($(e.currentTarget).val());
});
},
initializeCountrySelector() {
// Check if this form has shipping enabled
if (this.isShippingEnabled()) {
// Add country selector before payment options
this.insertCountrySelector();
}
},
isShippingEnabled() {
// Check if shipping is enabled for this form
// With form-level shipping, we check shipping_enabled setting
return window.formipayFormData?.shipping_enabled &&
window.formipayFormData.shipping_enabled !== 'no_shipping';
},
insertCountrySelector() {
const orderReviewTable = $('#formipay-review-order');
if (orderReviewTable.length === 0) {
return;
}
// Create country selector row before subtotal
const countryRow = `
<tr class="formipay-shipping-row">
<th>
<label for="shipping-country-${this.formId}">${formipay_shipping.labels.country || 'Shipping Country'}</label>
<select id="shipping-country-${this.formId}"
class="formipay-shipping-country"
name="shipping_country"
required>
<option value="">${formipay_shipping.labels.selectCountry || 'Select your country'}</option>
</select>
</th>
<td>
<span class="formipay-loading-spinner" style="display:none;">⏳</span>
</td>
</tr>
`;
orderReviewTable.find('tbody').append(countryRow);
// Populate countries from settings
this.loadCountries();
},
loadCountries() {
// Get supported countries from shipping settings
$.ajax({
url: formipay_admin.ajaxUrl,
type: 'POST',
data: {
action: 'formipay_get_supported_countries',
nonce: formipay_admin.nonce,
form_id: this.formId
},
success: (response) => {
if (response.success && response.data.countries) {
this.populateCountries(response.data.countries);
}
},
error: () => {
// Fallback: show all countries
this.populateCountries(this.getAllCountries());
}
});
},
populateCountries(countries) {
const select = $(`#shipping-country-${this.formId}`);
select.find('option:not([value=""])').remove();
$.each(countries, (code, name) => {
select.append(`<option value="${code}">${name}</option>`);
});
},
getAllCountries() {
// Fallback country list
return {
'ID': 'Indonesia',
'MY': 'Malaysia',
'SG': 'Singapore',
'TH': 'Thailand',
'VN': 'Vietnam',
'PH': 'Philippines',
'TW': 'Taiwan',
'HK': 'Hong Kong',
'IN': 'India',
'CN': 'China',
'JP': 'Japan',
'KR': 'South Korea',
'AU': 'Australia',
'NZ': 'New Zealand',
'GB': 'United Kingdom',
'US': 'United States',
'CA': 'Canada',
'FR': 'France',
'DE': 'Germany',
'IT': 'Italy',
'ES': 'Spain',
'NL': 'Netherlands',
'BE': 'Belgium',
'CH': 'Switzerland',
'AT': 'Austria',
'IE': 'Ireland',
'DK': 'Denmark',
'SE': 'Sweden',
'NO': 'Norway',
'FI': 'Finland',
};
},
onCountryChange(countryCode) {
this.selectedCountry = countryCode;
if (!countryCode) {
this.hideShippingMethods();
return;
}
// Get available shipping methods for this country
this.fetchShippingMethods(countryCode);
},
fetchShippingMethods(countryCode) {
const spinner = $('.formipay-loading-spinner');
spinner.show();
$.ajax({
url: formipay_admin.ajaxUrl,
type: 'POST',
data: {
action: 'formipay_get_shipping_methods',
nonce: formipay_public.nonce,
form_id: this.formId,
country: countryCode,
currency: formipay.currency_code || 'IDR'
},
success: (response) => {
spinner.hide();
if (response.success) {
this.availableMethods = response.data.methods || [];
this.displayShippingMethods(this.availableMethods, response.data.default_method);
} else {
this.showError(response.data.message || 'Unable to load shipping methods');
}
},
error: () => {
spinner.hide();
this.showError('Unable to connect to shipping service');
}
});
},
displayShippingMethods(methods, defaultMethod) {
// Remove existing shipping method selector if any
$('.formipay-shipping-method-row').remove();
if (methods.length === 0) {
this.showError('Shipping is not available for this form');
return;
}
// Get order total for percentage calculations
const orderTotal = this.getOrderTotal();
let methodsHtml = '<div class="formipay-shipping-methods">';
methodsHtml += `<input type="hidden" name="shipping_method" value="${defaultMethod}" class="formipay-shipping-method-input">`;
$.each(methods, (index, method) => {
const methodId = method.id;
const isFree = method.cost === 0;
const isPercentage = method.type === 'percentage';
// Calculate actual cost
let actualCost = method.cost;
let costDisplay = '';
if (isFree) {
costDisplay = 'FREE';
} else if (isPercentage) {
actualCost = (orderTotal * method.cost) / 100;
costDisplay = `${method.cost}% (${this.formatCost(actualCost, method.currency)})`;
} else {
costDisplay = this.formatCost(method.cost, method.currency);
}
methodsHtml += `
<div class="formipay-shipping-option" data-method="${methodId}">
<label class="formipay-shipping-label">
<input type="radio"
name="shipping_method_display"
value="${methodId}"
${methodId === defaultMethod ? 'checked' : ''}
class="formipay-shipping-method"
data-cost="${actualCost}"
data-type="${method.type || 'fixed'}"
data-base-cost="${method.cost}">
<span class="shipping-method-name">${method.name}</span>
${isFree ? '<span class="badge badge-free">FREE</span>' : ''}
<span class="shipping-method-cost">${costDisplay}</span>
${method.description ? `<span class="shipping-method-desc">${method.description}</span>` : ''}
</label>
</div>
`;
});
methodsHtml += '</div>';
// Insert after country selector
const countryRow = $('.formipay-shipping-row');
const shippingRow = `
<tr class="formipay-shipping-method-row">
<td colspan="2">
${methodsHtml}
</td>
</tr>
`;
countryRow.after(shippingRow);
// Bind shipping method change events
$('.formipay-shipping-method').on('change', (e) => {
const target = $(e.currentTarget);
const cost = parseFloat(target.data('cost'));
const type = target.data('type');
const baseCost = parseFloat(target.data('base-cost'));
const method = target.val();
// For percentage, recalculate in case order total changed
let finalCost = cost;
if (type === 'percentage') {
finalCost = (this.getOrderTotal() * baseCost) / 100;
}
// Update hidden input
$('.formipay-shipping-method-input').val(method);
// Update order total
this.updateOrderTotal(finalCost);
});
// Trigger change on default method to set initial cost
$(`input[name="shipping_method_display"][value="${defaultMethod}"]`).trigger('change');
},
hideShippingMethods() {
$('.formipay-shipping-method-row').remove();
},
getOrderTotal() {
// Get current order total (excluding shipping)
const subtotalRow = $('.formipay-total-row td').text();
const subtotal = this.parseCurrency(subtotalRow);
return subtotal;
},
updateOrderTotal(shippingCost) {
const currentTotal = this.getOrderTotal();
const newTotal = currentTotal + shippingCost;
// Update the total display
const totalRow = $('.formipay-grand-total-row td');
totalRow.text(this.formatCost(newTotal));
// Update submit button
const submitBtn = $('.formipay-submit-button');
const currentText = submitBtn.attr('data-button-text');
submitBtn.html(`${currentText} - ${this.formatCost(newTotal)}`);
},
formatCost(cost, currency) {
// Format cost using same format as product prices
const formatted = cost.toFixed(formipay.decimal_digits || 2);
return (currency || formipay.currency || '') + ' ' + formatted;
},
parseCurrency(text) {
// Parse currency string to get numeric value
// Removes currency symbols and formats
const numeric = text.replace(/[^\d.-]/g, '');
return parseFloat(numeric) || 0;
},
showError(message) {
const errorHtml = `
<div class="formipay-shipping-error notice notice-error">
<p>${message}</p>
</div>
`;
$('.formipay-shipping-method-row').html(`<td colspan="2">${errorHtml}</td>`);
}
};
// Initialize when document is ready
$(document).ready(() => {
if (typeof formipay_shipping !== 'undefined' && typeof formipay_shipping.labels !== 'undefined') {
FormipayCheckoutShipping.init();
}
});
// Expose for global access
window.FormipayCheckoutShipping = FormipayCheckoutShipping;
})(jQuery);