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:
dwindown
2026-04-18 12:33:50 +07:00
parent 42d8f2e3b6
commit 3e6c06178c
5 changed files with 1034 additions and 4 deletions

View 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;
}

View 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' };

View 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;
}

View 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>
);
}

View File

@@ -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">
<h1>{ __('Products', 'formipay') }</h1>
<p>{ __('Page content coming soon...', 'formipay') }</p>
<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>
<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>
);
}