diff --git a/src/admin/components/products/VariationPricingTable.css b/src/admin/components/products/VariationPricingTable.css new file mode 100644 index 000000000..a3096d169 --- /dev/null +++ b/src/admin/components/products/VariationPricingTable.css @@ -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; +} diff --git a/src/admin/components/products/VariationPricingTable.js b/src/admin/components/products/VariationPricingTable.js new file mode 100644 index 000000000..2618b2b71 --- /dev/null +++ b/src/admin/components/products/VariationPricingTable.js @@ -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 ( + <> + + + + + + + {showFlatPricing ? ( + <> + + + + ) : null} + + + + + + + {tableRows.map((row, rowIndex) => ( + toggleExpanded(rowIndex)} + onUpdatePrice={(currencyIndex, field, value) => + updatePrice(rowIndex, currencyIndex, field, value) + } + onUpdateField={(field, value) => updateRowField(rowIndex, field, value)} + onDelete={() => deleteRow(rowIndex)} + /> + ))} + +
{ __('Variation', 'formipay') }{ __('Price', 'formipay') }{ __('Sale Price', 'formipay') }{ __('Stock', 'formipay') }{ __('Weight', 'formipay') }{ __('Actions', 'formipay') }
+ + ); +} + +// Individual variation row component +function VariationRow({ row, rowIndex, showFlatPricing, defaultCurrencyCode, + onToggleExpanded, onUpdatePrice, onUpdateField, onDelete }) { + + return ( + <> + + + + { row.name } + + + {showFlatPricing ? ( + <> + onUpdatePrice(0, field, value)} + /> + onUpdatePrice(0, field, value)} + /> + + ) : null} + + + onUpdateField('stock', value)} + placeholder="Unlimited" + /> + + + + onUpdateField('weight', value)} + step="0.01" + /> + + + + + + + + {!showFlatPricing && row.expanded && ( + + + + + {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 ( + + + + + + ); + })} + +
+ { price.currency } + {isDefault && *} + + onUpdatePrice(currencyIndex, 'regular_price', e.target.value)} + step={step} + placeholder="Regular Price" + required={isDefault} + /> + + onUpdatePrice(currencyIndex, 'sale_price', e.target.value)} + step={step} + placeholder="Sale Price" + /> +
+ + + )} + + ); +} + +// 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 ( + + onChange(field, e.target.value)} + step={step} + placeholder="Auto" + /> + + ); +} + +// Icon helpers +const eyeOpened = { ...eye, icon: 'eyeOpened' }; +const eyeClosed = { ...eyeClosed, icon: 'eyeClosed' }; diff --git a/src/admin/components/settings/GlobalSettings.css b/src/admin/components/settings/GlobalSettings.css new file mode 100644 index 000000000..e91c9bb15 --- /dev/null +++ b/src/admin/components/settings/GlobalSettings.css @@ -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; +} diff --git a/src/admin/components/settings/GlobalSettings.js b/src/admin/components/settings/GlobalSettings.js new file mode 100644 index 000000000..a54d27a3b --- /dev/null +++ b/src/admin/components/settings/GlobalSettings.js @@ -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 ( +
+
+

+ + { __('Formipay Settings', 'formipay') } +

+
+ {saveStatus === 'saved' && ( + + { __('Settings saved!', 'formipay') } + + )} + {saveStatus === 'error' && ( + + { __('Save failed', 'formipay') } + + )} + +
+
+ +
+
+ {Object.entries(tabs).map(([key, tab]) => ( + + ))} +
+ +
+ {activeTab === 'general' && } + {activeTab === 'payment' && } + {activeTab === 'pages' && } + {activeTab === 'customer' && } +
+
+
+ ); +} + +function GeneralTab({ settings, updateSetting }) { + return ( + + + + updateSetting('enable_multicurrency', value)} + help={__('Allow customers to select currency', 'formipay')} + /> + + + {settings.enable_multicurrency && ( + <> + + + updateSetting('default_currency', value)} + /> + + + )} + + + ); +} + +function PaymentTab({ settings, updateSetting }) { + return ( + + + + + updateSetting('bank_transfer_timeout', parseInt(value))} + help={__('Auto-cancel unpaid orders after this time', 'formipay')} + /> + + + + ); +} + +function PagesTab({ settings, updateSetting }) { + return ( + + + + + updateSetting('thankyou_link', value)} + help={__('The URL slug for the thank you page', 'formipay')} + /> + + + + ); +} + +function CustomerTab() { + return ( + + + +

+ { __('Customer data settings are configured per-form', 'formipay') } +

+
+
+
+ ); +} diff --git a/src/admin/pages/Products.js b/src/admin/pages/Products.js index 08c06eb0a..4b0727635 100644 --- a/src/admin/pages/Products.js +++ b/src/admin/pages/Products.js @@ -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 ( +
+
+ +

{ __('Edit Product', 'formipay') }

+
+ +
+ +
+
+ ); + } + return ( -
-

{ __('Products', 'formipay') }

-

{ __('Page content coming soon...', 'formipay') }

+
+
+

{ __('Products', 'formipay') }

+ +
+ +

+ { __('Products list coming soon. Use the classic editor for now.', 'formipay') } +

); }