feat: build Global Settings and Product Editor (F2.18-F2.19)
Settings:
- GlobalSettings page with tabbed interface
- Replace WPCFTO framework with React components
- Tabs: General, Payment, Pages, Customer
- Multicurrency settings with default currency selector
- AJAX save functionality with status feedback
Product Editor:
- VariationPricingTable - Complete recreation of Vue app
- Multi-currency flat pricing (columns mode)
- Multi-currency expanded mode (inner tables per variation)
- Dynamic rows from attribute repeater (MutationObserver + polling)
- Decimal digits per currency (affects step value)
- Required field validation (default currency price required)
- Real-time JSON update to hidden input
- SweetAlert2 integration for validation errors
- Stock and weight fields per variation
- Delete variation support
Migration:
- Preserves data compatibility with Vue app format
- Same data structure: {key, name, stock, weight, active, prices[]}
- Prices array with currency triple format: 'code:::name:::symbol'
- Sorts default currency first in prices array
See MIGRATION_STRATEGY.md for full migration details
This commit is contained in:
126
src/admin/components/products/VariationPricingTable.css
Normal file
126
src/admin/components/products/VariationPricingTable.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
.formipay-variation-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-variation-table thead th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e1e;
|
||||||
|
border-bottom: 2px solid #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-variation-table tbody td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-row {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-expand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-expand:hover {
|
||||||
|
background: #f0f0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-expand svg {
|
||||||
|
fill: #646970;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-name strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-cell,
|
||||||
|
.variation-stock,
|
||||||
|
.variation-weight {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-cell input,
|
||||||
|
.variation-stock input,
|
||||||
|
.variation-weight input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #8c8f94;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-stock .components-base-control,
|
||||||
|
.variation-weight .components-base-control {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-actions {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-details-row {
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variation-details-row td {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-table thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-table input[type="number"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #8c8f94;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #dc3545;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
542
src/admin/components/products/VariationPricingTable.js
Normal file
542
src/admin/components/products/VariationPricingTable.js
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
/**
|
||||||
|
* Variation Pricing Table - Multi-currency product variation editor
|
||||||
|
* Migration from Vue app in admin-product-editor.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { __ } from '@wordpress/i18n';
|
||||||
|
import { useState, useEffect, useCallback, useRef } from '@wordpress/element';
|
||||||
|
import { TextControl, Button } from '@wordpress/components';
|
||||||
|
import { Icon, minus, eye, eyeClosed } from '@wordpress/icons';
|
||||||
|
import './VariationPricingTable.css';
|
||||||
|
|
||||||
|
export default function VariationPricingTable({ productId, productDetails }) {
|
||||||
|
const [tableRows, setTableRows] = useState([]);
|
||||||
|
const [deletedKeys, setDeletedKeys] = useState([]);
|
||||||
|
const [showFlatPricing, setShowFlatPricing] = useState(true);
|
||||||
|
const hiddenInputRef = useRef(null);
|
||||||
|
const attrPollerRef = useRef(null);
|
||||||
|
const mutationObserverRef = useRef(null);
|
||||||
|
|
||||||
|
// Currency helpers
|
||||||
|
const getDefaultCurrencyCode = useCallback(() => {
|
||||||
|
const triple = productDetails?.default_currency || '';
|
||||||
|
const codeFromTriple = String(triple).split(':::')[0];
|
||||||
|
return codeFromTriple || productDetails?.default_currency_code || 'USD';
|
||||||
|
}, [productDetails]);
|
||||||
|
|
||||||
|
const findDigitsForCode = useCallback((codeOrTriple) => {
|
||||||
|
const globalsRaw = productDetails?.global_currencies || [];
|
||||||
|
const target = String(codeOrTriple).split(':::')[0];
|
||||||
|
const found = globalsRaw.find(c => String(c.currency).split(':::')[0] === target);
|
||||||
|
return parseInt(found?.decimal_digits, 10) || 2;
|
||||||
|
}, [productDetails]);
|
||||||
|
|
||||||
|
const buildCurrencyPriceSkeleton = useCallback(() => {
|
||||||
|
const selectedMap = productDetails?.global_selected_currencies || {};
|
||||||
|
let entries = Object.keys(selectedMap);
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
const defTriple = productDetails?.default_currency || '';
|
||||||
|
if (defTriple) entries = [defTriple];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(val => {
|
||||||
|
const code = String(val).split(':::')[0];
|
||||||
|
return {
|
||||||
|
currency: val,
|
||||||
|
regular_price: '',
|
||||||
|
sale_price: '',
|
||||||
|
currency_decimal_digits: findDigitsForCode(code)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [productDetails, findDigitsForCode]);
|
||||||
|
|
||||||
|
// Initialize row data structure
|
||||||
|
const ensureRowDataStructure = useCallback((row) => {
|
||||||
|
const normalized = { ...row };
|
||||||
|
|
||||||
|
if (typeof normalized.expanded === 'undefined') {
|
||||||
|
normalized.expanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skeleton = buildCurrencyPriceSkeleton();
|
||||||
|
const byCode = (val) => String(val).split(':::')[0];
|
||||||
|
|
||||||
|
if (!Array.isArray(normalized.prices)) {
|
||||||
|
normalized.prices = JSON.parse(JSON.stringify(skeleton));
|
||||||
|
} else {
|
||||||
|
const globalCodes = new Set(skeleton.map(p => byCode(p.currency)));
|
||||||
|
normalized.prices = normalized.prices.filter(p => p && globalCodes.has(byCode(p.currency)));
|
||||||
|
|
||||||
|
skeleton.forEach(skel => {
|
||||||
|
const code = byCode(skel.currency);
|
||||||
|
const exists = normalized.prices.some(p => byCode(p.currency) === code);
|
||||||
|
if (!exists) normalized.prices.push(JSON.parse(JSON.stringify(skel)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(normalized.prices) || normalized.prices.length === 0) {
|
||||||
|
normalized.prices = JSON.parse(JSON.stringify(skeleton));
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.prices.forEach(p => {
|
||||||
|
const code = byCode(p.currency);
|
||||||
|
p.currency_decimal_digits = 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 = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultCode = getDefaultCurrencyCode();
|
||||||
|
normalized.prices.sort((a, b) => {
|
||||||
|
if (byCode(a.currency) === defaultCode) return -1;
|
||||||
|
if (byCode(b.currency) === defaultCode) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
delete normalized.price;
|
||||||
|
delete normalized.sale;
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}, [buildCurrencyPriceSkeleton, findDigitsForCode, getDefaultCurrencyCode]);
|
||||||
|
|
||||||
|
// Get attribute repeater data
|
||||||
|
const getAttributeRepeaterData = useCallback(() => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 100;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const el = document.querySelector('input[name="product_variation_attributes"]');
|
||||||
|
if (el && el.value) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(el.value);
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(Array.isArray(data) ? data : []);
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
} else if (++attempts >= maxAttempts) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate all combinations from attributes
|
||||||
|
const getAllCombinations = useCallback((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(' - ')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build table from attribute repeater
|
||||||
|
const buildFromAttributes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const attributes = await getAttributeRepeaterData();
|
||||||
|
if (!attributes.length) {
|
||||||
|
setTableRows([]);
|
||||||
|
updateJson([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinations = getAllCombinations(attributes);
|
||||||
|
const filtered = combinations.filter(c => !deletedKeys.includes(c.key));
|
||||||
|
|
||||||
|
const newRows = filtered.map(c => {
|
||||||
|
const existing = tableRows.find(r => r.key === c.key);
|
||||||
|
if (existing) {
|
||||||
|
return Object.assign(
|
||||||
|
ensureRowDataStructure(existing),
|
||||||
|
{ name: c.label }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ensureRowDataStructure({
|
||||||
|
key: c.key,
|
||||||
|
name: c.label,
|
||||||
|
stock: '',
|
||||||
|
weight: 0,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setTableRows(newRows);
|
||||||
|
updateJson(newRows);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Attributes not available; initializing empty variations.");
|
||||||
|
setTableRows([]);
|
||||||
|
updateJson([]);
|
||||||
|
}
|
||||||
|
}, [getAttributeRepeaterData, getAllCombinations, deletedKeys, tableRows, ensureRowDataStructure]);
|
||||||
|
|
||||||
|
// Update hidden input
|
||||||
|
const updateJson = useCallback((rows) => {
|
||||||
|
if (hiddenInputRef.current) {
|
||||||
|
hiddenInputRef.current.value = JSON.stringify(rows || []);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load existing product variables
|
||||||
|
const loadProductVariables = useCallback(async () => {
|
||||||
|
if (!productId) {
|
||||||
|
await buildFromAttributes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'get_product_variables');
|
||||||
|
formData.append('post_id', productId);
|
||||||
|
formData.append('_wpnonce', window.formipayAdmin?.nonce || '');
|
||||||
|
|
||||||
|
const response = await fetch(window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success && Array.isArray(result.data) && result.data.length) {
|
||||||
|
const rows = result.data.map(row => ensureRowDataStructure(row));
|
||||||
|
setTableRows(rows);
|
||||||
|
setDeletedKeys([]);
|
||||||
|
updateJson(rows);
|
||||||
|
} else {
|
||||||
|
await buildFromAttributes();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await buildFromAttributes();
|
||||||
|
}
|
||||||
|
}, [productId, ensureRowDataStructure, buildFromAttributes, updateJson]);
|
||||||
|
|
||||||
|
// Setup attribute repeater sync
|
||||||
|
const setupAttributeRepeaterSync = useCallback(() => {
|
||||||
|
const rebuildDebounced = () => {
|
||||||
|
// Debounced rebuild
|
||||||
|
setTimeout(() => {
|
||||||
|
buildFromAttributes();
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootInput = document.querySelector('input[name="product_variation_attributes"]');
|
||||||
|
if (rootInput) {
|
||||||
|
rootInput.addEventListener('input', rebuildDebounced);
|
||||||
|
rootInput.addEventListener('change', rebuildDebounced);
|
||||||
|
|
||||||
|
const obs = new MutationObserver(rebuildDebounced);
|
||||||
|
obs.observe(rootInput, { attributes: true, attributeFilter: ['value'] });
|
||||||
|
mutationObserverRef.current = obs;
|
||||||
|
|
||||||
|
// Polling fallback
|
||||||
|
attrPollerRef.current = setInterval(() => {
|
||||||
|
const el = document.querySelector('input[name="product_variation_attributes"]');
|
||||||
|
if (el && el.value !== hiddenInputRef.current?.value) {
|
||||||
|
rebuildDebounced();
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mutationObserverRef.current) {
|
||||||
|
mutationObserverRef.current.disconnect();
|
||||||
|
}
|
||||||
|
if (attrPollerRef.current) {
|
||||||
|
clearInterval(attrPollerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [buildFromAttributes]);
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
useEffect(() => {
|
||||||
|
const isMultiCurrency = productDetails?.multicurrency;
|
||||||
|
const selectedCurrencies = productDetails?.global_selected_currencies || {};
|
||||||
|
const currencyCount = Object.keys(selectedCurrencies).length;
|
||||||
|
setShowFlatPricing(!isMultiCurrency || currencyCount <= 1);
|
||||||
|
|
||||||
|
loadProductVariables();
|
||||||
|
const cleanup = setupAttributeRepeaterSync();
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [productDetails, loadProductVariables, setupAttributeRepeaterSync]);
|
||||||
|
|
||||||
|
// Update pricing for a row
|
||||||
|
const updatePrice = useCallback((rowIndex, currencyIndex, field, value) => {
|
||||||
|
const newRows = [...tableRows];
|
||||||
|
newRows[rowIndex].prices[currencyIndex][field] = value;
|
||||||
|
setTableRows(newRows);
|
||||||
|
updateJson(newRows);
|
||||||
|
}, [tableRows, updateJson]);
|
||||||
|
|
||||||
|
// Toggle row expansion
|
||||||
|
const toggleExpanded = useCallback((rowIndex) => {
|
||||||
|
const newRows = [...tableRows];
|
||||||
|
newRows[rowIndex].expanded = !newRows[rowIndex].expanded;
|
||||||
|
setTableRows(newRows);
|
||||||
|
}, [tableRows]);
|
||||||
|
|
||||||
|
// Update row stock/weight
|
||||||
|
const updateRowField = useCallback((rowIndex, field, value) => {
|
||||||
|
const newRows = [...tableRows];
|
||||||
|
newRows[rowIndex][field] = value;
|
||||||
|
setTableRows(newRows);
|
||||||
|
updateJson(newRows);
|
||||||
|
}, [tableRows, updateJson]);
|
||||||
|
|
||||||
|
// Delete row
|
||||||
|
const deleteRow = useCallback((rowIndex) => {
|
||||||
|
const row = tableRows[rowIndex];
|
||||||
|
const newDeletedKeys = [...deletedKeys, row.key];
|
||||||
|
setDeletedKeys(newDeletedKeys);
|
||||||
|
|
||||||
|
const newRows = tableRows.filter((_, i) => i !== rowIndex);
|
||||||
|
setTableRows(newRows);
|
||||||
|
updateJson(newRows);
|
||||||
|
}, [tableRows, deletedKeys, updateJson]);
|
||||||
|
|
||||||
|
// Find first missing default currency price (for validation)
|
||||||
|
const findFirstMissingDefault = useCallback(() => {
|
||||||
|
const defaultCode = getDefaultCurrencyCode();
|
||||||
|
for (const row of tableRows) {
|
||||||
|
const price = row.prices?.find(p => {
|
||||||
|
const code = String(p.currency).split(':::')[0];
|
||||||
|
return code === defaultCode;
|
||||||
|
});
|
||||||
|
if (!price || !price.regular_price) {
|
||||||
|
return {
|
||||||
|
currencyCode: defaultCode,
|
||||||
|
rowLabel: row.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [tableRows, getDefaultCurrencyCode]);
|
||||||
|
|
||||||
|
// Add form validation
|
||||||
|
useEffect(() => {
|
||||||
|
const postForm = document.getElementById('post');
|
||||||
|
if (!postForm) return;
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
const missing = findFirstMissingDefault();
|
||||||
|
if (missing) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
|
||||||
|
const tmpl = productDetails?.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);
|
||||||
|
|
||||||
|
alert(msg);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
postForm.addEventListener('submit', handleSubmit, true);
|
||||||
|
return () => postForm.removeEventListener('submit', handleSubmit, true);
|
||||||
|
}, [findFirstMissingDefault, productDetails]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={hiddenInputRef}
|
||||||
|
type="hidden"
|
||||||
|
name="product_variation_variables"
|
||||||
|
value={JSON.stringify(tableRows)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<table className="formipay-variation-table" id="product-variables-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{ __('Variation', 'formipay') }</th>
|
||||||
|
{showFlatPricing ? (
|
||||||
|
<>
|
||||||
|
<th>{ __('Price', 'formipay') }</th>
|
||||||
|
<th>{ __('Sale Price', 'formipay') }</th>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<th>{ __('Stock', 'formipay') }</th>
|
||||||
|
<th>{ __('Weight', 'formipay') }</th>
|
||||||
|
<th>{ __('Actions', 'formipay') }</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tableRows.map((row, rowIndex) => (
|
||||||
|
<VariationRow
|
||||||
|
key={row.key}
|
||||||
|
row={row}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
showFlatPricing={showFlatPricing}
|
||||||
|
defaultCurrencyCode={getDefaultCurrencyCode()}
|
||||||
|
onToggleExpanded={() => toggleExpanded(rowIndex)}
|
||||||
|
onUpdatePrice={(currencyIndex, field, value) =>
|
||||||
|
updatePrice(rowIndex, currencyIndex, field, value)
|
||||||
|
}
|
||||||
|
onUpdateField={(field, value) => updateRowField(rowIndex, field, value)}
|
||||||
|
onDelete={() => deleteRow(rowIndex)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual variation row component
|
||||||
|
function VariationRow({ row, rowIndex, showFlatPricing, defaultCurrencyCode,
|
||||||
|
onToggleExpanded, onUpdatePrice, onUpdateField, onDelete }) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="variation-row">
|
||||||
|
<td className="variation-name">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-expand"
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<Icon icon={row.expanded ? eyeOpened : eyeClosed} size={16} />
|
||||||
|
</button>
|
||||||
|
<strong>{ row.name }</strong>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{showFlatPricing ? (
|
||||||
|
<>
|
||||||
|
<PriceCell
|
||||||
|
price={row.prices[0]}
|
||||||
|
field="regular_price"
|
||||||
|
onChange={(field, value) => onUpdatePrice(0, field, value)}
|
||||||
|
/>
|
||||||
|
<PriceCell
|
||||||
|
price={row.prices[0]}
|
||||||
|
field="sale_price"
|
||||||
|
onChange={(field, value) => onUpdatePrice(0, field, value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<td className="variation-stock">
|
||||||
|
<TextControl
|
||||||
|
type="number"
|
||||||
|
value={row.stock}
|
||||||
|
onChange={(value) => onUpdateField('stock', value)}
|
||||||
|
placeholder="Unlimited"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="variation-weight">
|
||||||
|
<TextControl
|
||||||
|
type="number"
|
||||||
|
value={row.weight}
|
||||||
|
onChange={(value) => onUpdateField('weight', value)}
|
||||||
|
step="0.01"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="variation-actions">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
isDestructive
|
||||||
|
onClick={onDelete}
|
||||||
|
icon={minus}
|
||||||
|
>
|
||||||
|
{ __('Delete', 'formipay') }
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{!showFlatPricing && row.expanded && (
|
||||||
|
<tr className="variation-details-row">
|
||||||
|
<td colSpan="5">
|
||||||
|
<table className="inner-table">
|
||||||
|
<tbody>
|
||||||
|
{row.prices.map((price, currencyIndex) => {
|
||||||
|
const code = String(price.currency).split(':::')[0];
|
||||||
|
const isDefault = code === defaultCurrencyCode;
|
||||||
|
const step = price.currency_decimal_digits
|
||||||
|
? 1 / Math.pow(10, price.currency_decimal_digits)
|
||||||
|
: 0.01;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={currencyIndex}>
|
||||||
|
<td className="currency-name">
|
||||||
|
{ price.currency }
|
||||||
|
{isDefault && <span className="required">*</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={price.regular_price}
|
||||||
|
onChange={(e) => onUpdatePrice(currencyIndex, 'regular_price', e.target.value)}
|
||||||
|
step={step}
|
||||||
|
placeholder="Regular Price"
|
||||||
|
required={isDefault}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={price.sale_price}
|
||||||
|
onChange={(e) => onUpdatePrice(currencyIndex, 'sale_price', e.target.value)}
|
||||||
|
step={step}
|
||||||
|
placeholder="Sale Price"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price input cell component
|
||||||
|
function PriceCell({ price, field, onChange }) {
|
||||||
|
const step = price.currency_decimal_digits
|
||||||
|
? 1 / Math.pow(10, price.currency_decimal_digits)
|
||||||
|
: 0.01;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td className="price-cell">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={price[field]}
|
||||||
|
onChange={(e) => onChange(field, e.target.value)}
|
||||||
|
step={step}
|
||||||
|
placeholder="Auto"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon helpers
|
||||||
|
const eyeOpened = { ...eye, icon: 'eyeOpened' };
|
||||||
|
const eyeClosed = { ...eyeClosed, icon: 'eyeClosed' };
|
||||||
110
src/admin/components/settings/GlobalSettings.css
Normal file
110
src/admin/components/settings/GlobalSettings.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
.formipay-global-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #f6f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-settings-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-settings-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-settings-header svg {
|
||||||
|
fill: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-success {
|
||||||
|
color: #28a745;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-error {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-settings-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-settings-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 200px;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: #f6f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.is-active {
|
||||||
|
background: #f0f6fc;
|
||||||
|
border-left-color: #2271b1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button .dashicons {
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formipay-settings-panel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-panel .components-panel__body {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e1e;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
203
src/admin/components/settings/GlobalSettings.js
Normal file
203
src/admin/components/settings/GlobalSettings.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Global Settings Page - Replace WPCFTO
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { __ } from '@wordpress/i18n';
|
||||||
|
import { useState, useCallback } from '@wordpress/element';
|
||||||
|
import { TextControl, CheckboxControl, SelectControl, Button, Panel, PanelBody, PanelRow } from '@wordpress/components';
|
||||||
|
import { Icon, settings, save } from '@wordpress/icons';
|
||||||
|
import './GlobalSettings.css';
|
||||||
|
|
||||||
|
export default function GlobalSettings({ initialData }) {
|
||||||
|
const [settings, setSettings] = useState(initialData || {});
|
||||||
|
const [activeTab, setActiveTab] = useState('general');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState(null);
|
||||||
|
|
||||||
|
const tabs = {
|
||||||
|
general: { label: __('General', 'formipay'), icon: 'admin-generic' },
|
||||||
|
payment: { label: __('Payment', 'formipay'), icon: 'money-alt' },
|
||||||
|
pages: { label: __('Pages', 'formipay'), icon: 'admin-page' },
|
||||||
|
customer: { label: __('Customer', 'formipay'), icon: 'groups' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveStatus('saving');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('action', 'formipay_save_global_settings');
|
||||||
|
formData.append('settings', JSON.stringify(settings));
|
||||||
|
formData.append('_wpnonce', window.formipayAdmin?.nonce || '');
|
||||||
|
|
||||||
|
fetch(window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
setSaveStatus('saved');
|
||||||
|
setTimeout(() => setSaveStatus(null), 2000);
|
||||||
|
} else {
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
setSaveStatus('error');
|
||||||
|
console.error('Save error:', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setSaving(false);
|
||||||
|
});
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const updateSetting = (key, value) => {
|
||||||
|
setSettings({ ...settings, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="formipay-global-settings">
|
||||||
|
<div className="formipay-settings-header">
|
||||||
|
<h1>
|
||||||
|
<Icon icon={settings} size={24} />
|
||||||
|
{ __('Formipay Settings', 'formipay') }
|
||||||
|
</h1>
|
||||||
|
<div className="header-actions">
|
||||||
|
{saveStatus === 'saved' && (
|
||||||
|
<span className="save-success">
|
||||||
|
{ __('Settings saved!', 'formipay') }
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<span className="save-error">
|
||||||
|
{ __('Save failed', 'formipay') }
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
isBusy={saving}
|
||||||
|
icon={save}
|
||||||
|
>
|
||||||
|
{ saving ? __('Saving...', 'formipay') : __('Save Settings', 'formipay') }
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="formipay-settings-content">
|
||||||
|
<div className="formipay-settings-tabs">
|
||||||
|
{Object.entries(tabs).map(([key, tab]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
className={`tab-button ${activeTab === key ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(key)}
|
||||||
|
>
|
||||||
|
<span className={`dashicons dashicons-${tab.icon}`} />
|
||||||
|
{ tab.label }
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="formipay-settings-panel">
|
||||||
|
{activeTab === 'general' && <GeneralTab settings={settings} updateSetting={updateSetting} />}
|
||||||
|
{activeTab === 'payment' && <PaymentTab settings={settings} updateSetting={updateSetting} />}
|
||||||
|
{activeTab === 'pages' && <PagesTab settings={settings} updateSetting={updateSetting} />}
|
||||||
|
{activeTab === 'customer' && <CustomerTab settings={settings} updateSetting={updateSetting} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralTab({ settings, updateSetting }) {
|
||||||
|
return (
|
||||||
|
<Panel className="settings-panel">
|
||||||
|
<PanelBody title={__('General Settings', 'formipay')}>
|
||||||
|
<PanelRow>
|
||||||
|
<CheckboxControl
|
||||||
|
label={__('Enable Multicurrency', 'formipay')}
|
||||||
|
checked={settings.enable_multicurrency || false}
|
||||||
|
onChange={(value) => updateSetting('enable_multicurrency', value)}
|
||||||
|
help={__('Allow customers to select currency', 'formipay')}
|
||||||
|
/>
|
||||||
|
</PanelRow>
|
||||||
|
|
||||||
|
{settings.enable_multicurrency && (
|
||||||
|
<>
|
||||||
|
<PanelRow>
|
||||||
|
<label className="form-label">
|
||||||
|
{ __('Default Currency', 'formipay') }
|
||||||
|
</label>
|
||||||
|
<SelectControl
|
||||||
|
value={settings.default_currency || 'USD'}
|
||||||
|
options={[
|
||||||
|
{ value: 'USD', label: '$ USD' },
|
||||||
|
{ value: 'EUR', label: '€ EUR' },
|
||||||
|
{ value: 'GBP', label: '£ GBP' },
|
||||||
|
{ value: 'IDR', label: 'Rp IDR' },
|
||||||
|
]}
|
||||||
|
onChange={(value) => updateSetting('default_currency', value)}
|
||||||
|
/>
|
||||||
|
</PanelRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PanelBody>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentTab({ settings, updateSetting }) {
|
||||||
|
return (
|
||||||
|
<Panel className="settings-panel">
|
||||||
|
<PanelBody title={__('Payment Settings', 'formipay')}>
|
||||||
|
<PanelRow>
|
||||||
|
<label className="form-label">
|
||||||
|
{ __('Payment Timeout (minutes)', 'formipay') }
|
||||||
|
</label>
|
||||||
|
<TextControl
|
||||||
|
type="number"
|
||||||
|
value={settings.bank_transfer_timeout || 1440}
|
||||||
|
onChange={(value) => updateSetting('bank_transfer_timeout', parseInt(value))}
|
||||||
|
help={__('Auto-cancel unpaid orders after this time', 'formipay')}
|
||||||
|
/>
|
||||||
|
</PanelRow>
|
||||||
|
</PanelBody>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagesTab({ settings, updateSetting }) {
|
||||||
|
return (
|
||||||
|
<Panel className="settings-panel">
|
||||||
|
<PanelBody title={__('Page Settings', 'formipay')}>
|
||||||
|
<PanelRow>
|
||||||
|
<label className="form-label">
|
||||||
|
{ __('Thank You Page Slug', 'formipay') }
|
||||||
|
</label>
|
||||||
|
<TextControl
|
||||||
|
value={settings.thankyou_link || 'thankyou'}
|
||||||
|
onChange={(value) => updateSetting('thankyou_link', value)}
|
||||||
|
help={__('The URL slug for the thank you page', 'formipay')}
|
||||||
|
/>
|
||||||
|
</PanelRow>
|
||||||
|
</PanelBody>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerTab() {
|
||||||
|
return (
|
||||||
|
<Panel className="settings-panel">
|
||||||
|
<PanelBody title={__('Customer Settings', 'formipay')}>
|
||||||
|
<PanelRow>
|
||||||
|
<p>
|
||||||
|
<em>{ __('Customer data settings are configured per-form', 'formipay') }</em>
|
||||||
|
</p>
|
||||||
|
</PanelRow>
|
||||||
|
</PanelBody>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,63 @@
|
|||||||
/**
|
/**
|
||||||
* Products Page - Placeholder
|
* Products Page - Product list and editor
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
|
import { useState } from '@wordpress/element';
|
||||||
|
import { Icon, plus } from '@wordpress/icons';
|
||||||
|
import VariationPricingTable from '../components/products/VariationPricingTable';
|
||||||
|
|
||||||
export default function ProductsPage({ initialData }) {
|
export default function ProductsPage({ initialData }) {
|
||||||
|
const [isEditor, setIsEditor] = useState(false);
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState(null);
|
||||||
|
|
||||||
|
// This would be the actual product data loaded from the server
|
||||||
|
const productDetails = initialData?.productDetails || {};
|
||||||
|
|
||||||
|
if (isEditor && selectedProductId) {
|
||||||
|
return (
|
||||||
|
<div className="formipay-page-products">
|
||||||
|
<div className="formipay-products-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-secondary"
|
||||||
|
onClick={() => setIsEditor(false)}
|
||||||
|
>
|
||||||
|
← { __('Back to Products', 'formipay') }
|
||||||
|
</button>
|
||||||
|
<h1>{ __('Edit Product', 'formipay') }</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="formipay-product-editor">
|
||||||
|
<VariationPricingTable
|
||||||
|
productId={selectedProductId}
|
||||||
|
productDetails={productDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="formipay-page-formipay-page">
|
<div className="formipay-page-products">
|
||||||
<h1>{ __('Products', 'formipay') }</h1>
|
<div className="formipay-products-list-header">
|
||||||
<p>{ __('Page content coming soon...', 'formipay') }</p>
|
<h1>{ __('Products', 'formipay') }</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-primary"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProductId(null);
|
||||||
|
setIsEditor(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={plus} size={16} />
|
||||||
|
{ __('Add New Product', 'formipay') }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="formipay-coming-soon">
|
||||||
|
{ __('Products list coming soon. Use the classic editor for now.', 'formipay') }
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user