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 { useState } from '@wordpress/element';
|
||||
import { Icon, plus } from '@wordpress/icons';
|
||||
import VariationPricingTable from '../components/products/VariationPricingTable';
|
||||
|
||||
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-formipay-page">
|
||||
<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 (
|
||||
<div className="formipay-page-products">
|
||||
<div className="formipay-products-list-header">
|
||||
<h1>{ __('Products', 'formipay') }</h1>
|
||||
<p>{ __('Page content coming soon...', 'formipay') }</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user