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 ( + <> + + +
| { __('Variation', 'formipay') } | + {showFlatPricing ? ( + <> +{ __('Price', 'formipay') } | +{ __('Sale Price', 'formipay') } | + > + ) : null} +{ __('Stock', 'formipay') } | +{ __('Weight', 'formipay') } | +{ __('Actions', 'formipay') } | +
|---|
| + { 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" + /> + | +
+ { __('Customer data settings are configured per-form', 'formipay') } +
+{ __('Page content coming soon...', 'formipay') }
++ { __('Products list coming soon. Use the classic editor for now.', 'formipay') } +