feat: add React metabox island for coupon editor

- Create CouponMetabox React component with WPCFTO design system
- Add MetaboxLayout with vertical tabs (Rules, Restrictions)
- Implement Rules tab: active toggle, type radio, amount fields, multi-currency support
- Implement Restrictions tab: usage limit, date limit, autocomplete for forms/products/customers
- Add metabox registration in Coupon.php for formipay-coupon post type
- Update ReactAdmin to load assets on post.php edit screens
- Add autocomplete AJAX handler for relation fields
- Disable old WPCFTO metabox in favor of React island
This commit is contained in:
dwindown
2026-04-19 07:08:54 +07:00
parent bde43d8c66
commit d1de0015be
9 changed files with 854 additions and 77 deletions

View File

@@ -0,0 +1,146 @@
/**
* Coupon Metabox Styles
* WPCFTO-inspired design for React metabox island
*/
.formipay-coupon-metabox {
margin: -12px;
}
.formipay-metabox-actions {
padding: 20px 30px;
background-color: #fff;
border-top: 1px solid #f0f0f1;
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* Radio Group */
.formipay-radio-group {
display: flex;
gap: 16px;
}
.formipay-radio {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 8px 16px;
border: 2px solid var(--formipay-color-border-dark);
border-radius: var(--formipay-radius-lg);
transition: all var(--formipay-transition-fast);
}
.formipay-radio input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.formipay-radio span {
font-weight: var(--formipay-font-weight-medium);
color: var(--formipay-color-text-muted);
}
.formipay-radio:hover {
border-color: var(--formipay-color-primary);
}
.formipay-radio.active {
background-color: var(--formipay-color-primary);
border-color: var(--formipay-color-primary);
}
.formipay-radio.active span {
color: white;
}
/* Autocomplete Field */
.formipay-autocomplete {
position: relative;
}
.formipay-autocomplete-selected {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.formipay-autocomplete-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background-color: var(--formipay-color-primary);
color: white;
border-radius: 4px;
font-size: var(--formipay-font-size-sm);
}
.formipay-autocomplete-remove {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.formipay-autocomplete-remove:hover {
opacity: 0.8;
}
.formipay-autocomplete-input-wrapper {
position: relative;
}
.formipay-autocomplete-loading {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--formipay-color-text-muted);
}
.formipay-autocomplete-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--formipay-color-border-dark);
border-radius: var(--formipay-radius-sm);
box-shadow: var(--formipay-shadow-md);
z-index: 100;
margin-top: 4px;
}
.formipay-autocomplete-result {
padding: 10px 16px;
cursor: pointer;
transition: background-color var(--formipay-transition-fast);
}
.formipay-autocomplete-result:hover {
background-color: var(--formipay-color-content-bg);
}
.formipay-autocomplete-result:first-child {
border-radius: var(--formipay-radius-sm) var(--formipay-radius-sm) 0 0;
}
.formipay-autocomplete-result:last-child {
border-radius: 0 0 var(--formipay-radius-sm) var(--formipay-radius-sm);
}

View File

@@ -0,0 +1,545 @@
/**
* Coupon Metabox - React metabox island for post.php editor
* Uses WPCFTO design system components
*/
import { useState, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { MetaboxLayout, TabNav, TabPanel, Field, Input, Checkbox, Textarea, Button, Notice, GroupTitle } from '../../design-system';
// Currency helper functions
const getGlobalCurrencies = () => {
if (window.formipayGlobalCurrencies) {
return window.formipayGlobalCurrencies;
}
return [];
};
const getCurrencySymbol = (currencyRaw) => {
const parts = currencyRaw.split(':::');
return parts[1] || currencyRaw;
};
const getCurrencyCode = (currencyRaw) => {
const parts = currencyRaw.split(':::');
return parts[0] || currencyRaw;
};
const getFlagByCurrency = (currencyRaw) => {
if (window.formipayGetFlag) {
return window.formipayGetFlag(currencyRaw);
}
return '';
};
export default function CouponMetabox({ postId }) {
const [activeTab, setActiveTab] = useState('rules');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
// Form state
const [formData, setFormData] = useState({
active: 'on',
type: 'percentage',
amount_percentage: '',
case_sensitive: '',
free_shipping: '',
quantity_active: '',
use_limit: '',
date_limit: '',
amounts_fixed: {},
max_amounts: {},
forms: [],
products: [],
customers: [],
});
const tabs = [
{ id: 'rules', label: __('Rules', 'formipay'), icon: 'fa fa-cog' },
{ id: 'restriction', label: __('Restrictions', 'formipay'), icon: 'fa fa-lock' },
];
// Load coupon data
useEffect(() => {
if (postId > 0) {
loadCouponData();
} else {
setLoading(false);
}
}, [postId]);
const loadCouponData = async () => {
try {
const response = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-get-coupon`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
id: postId,
_wpnonce: window.formipayAdmin?.nonce || '',
}),
});
const result = await response.json();
if (result.success) {
const data = result.data;
setFormData(prev => ({
...prev,
active: data.active || 'on',
type: data.type || 'percentage',
amount_percentage: data.amount_percentage || '',
case_sensitive: data.case_sensitive || '',
free_shipping: data.free_shipping || '',
quantity_active: data.quantity_active || '',
use_limit: data.use_limit || '',
date_limit: data.date_limit || '',
amounts_fixed: data.amounts_fixed?.reduce((acc, item) => {
acc[item.symbol] = item.amount;
return acc;
}, {}) || {},
max_amounts: data.max_amounts?.reduce((acc, item) => {
acc[item.symbol] = item.amount;
return acc;
}, {}) || {},
forms: data.forms || [],
products: data.products || [],
customers: data.customers || [],
}));
}
} catch (error) {
console.error('Failed to load coupon:', error);
setMessage({ type: 'error', text: __('Failed to load coupon data.', 'formipay') });
} finally {
setLoading(false);
}
};
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setSaving(true);
setMessage(null);
try {
const params = new URLSearchParams({
id: postId,
title: document.querySelector('#title input')?.value || '',
_wpnonce: window.formipayAdmin?.nonce || '',
...formData,
});
// Add fixed amounts
Object.entries(formData.amounts_fixed).forEach(([symbol, amount]) => {
params.append(`amount_fixed_${symbol}`, amount);
});
// Add max amounts
Object.entries(formData.max_amounts).forEach(([symbol, amount]) => {
params.append(`max_amount_${symbol}`, amount);
});
const response = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-save-coupon`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
});
const result = await response.json();
if (result.success) {
setMessage({ type: 'success', text: result.data.message || __('Coupon saved successfully.', 'formipay') });
} else {
setMessage({ type: 'error', text: result.data?.message || __('Failed to save coupon.', 'formipay') });
}
} catch (error) {
console.error('Failed to save coupon:', error);
setMessage({ type: 'error', text: __('Failed to save coupon.', 'formipay') });
} finally {
setSaving(false);
}
};
const isPercentage = formData.type === 'percentage';
const isFixed = formData.type === 'fixed';
if (loading) {
return <div className="formipay-loading">{__('Loading...', 'formipay')}</div>;
}
return (
<div className="formipay-coupon-metabox">
{message && (
<Notice
type={message.type}
onDismiss={() => setMessage(null)}
>
{message.text}
</Notice>
)}
<MetaboxLayout
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
>
<TabPanel tabs={tabs} activeTab={activeTab}>
{(tab) => {
if (tab.id === 'rules') {
return (
<div className="formipay-tab-content">
{/* General Settings */}
<GroupTitle title={__('General', 'formipay')} icon="fa fa-cog" />
<Field
label={__('Active', 'formipay')}
description={__('Enable this coupon.', 'formipay')}
>
<Checkbox
checked={formData.active === 'on'}
onChange={(e) => handleChange('active', e.target.checked ? 'on' : '')}
/>
</Field>
<Field
label={__('Type', 'formipay')}
description={__('Choose discount type.', 'formipay')}
required
>
<div className="formipay-radio-group">
<label className={`formipay-radio ${isFixed ? 'active' : ''}`}>
<input
type="radio"
name="type"
value="fixed"
checked={isFixed}
onChange={(e) => handleChange('type', e.target.value)}
/>
<span>{__('Fixed', 'formipay')}</span>
</label>
<label className={`formipay-radio ${isPercentage ? 'active' : ''}`}>
<input
type="radio"
name="type"
value="percentage"
checked={isPercentage}
onChange={(e) => handleChange('type', e.target.value)}
/>
<span>{__('Percentage', 'formipay')}</span>
</label>
</div>
</Field>
{isPercentage && (
<Field
label={__('Amount', 'formipay')}
description={__('Discount percentage.', 'formipay')}
required
>
<Input
type="number"
min="0"
max="100"
step="0.01"
value={formData.amount_percentage}
onChange={(e) => handleChange('amount_percentage', e.target.value)}
/>
</Field>
)}
{/* Fixed Amount Section */}
{isFixed && (
<>
<GroupTitle title={__('Discount Amount', 'formipay')} icon="fa fa-money" />
{getGlobalCurrencies().map((currency) => {
const symbol = getCurrencySymbol(currency.currency);
const currencyCode = getCurrencyCode(currency.currency);
const flag = getFlagByCurrency(currency.currency);
const step = currency.decimal_digits > 0 ? 1 / (currency.decimal_digits * 10) : 1;
return (
<div key={currencyCode} className="formipay-generic-field">
<div className="formipay-field-aside">
<div className="formipay-field-label required">
<span className="formipay-field-label-text">
{flag && <img src={flag} alt="" width="18" style={{ verticalAlign: 'middle', marginRight: '4px' }} />}
{__('Amount in', 'formipay')} {symbol}
</span>
</div>
</div>
<div className="formipay-field-content">
<Input
type="number"
min="0"
step={step}
placeholder={__('Enter Amount...', 'formipay')}
value={formData.amounts_fixed[symbol] || ''}
onChange={(e) => handleChange('amounts_fixed', {
...formData.amounts_fixed,
[symbol]: e.target.value
})}
/>
</div>
</div>
);
})}
</>
)}
{/* Max Discount Section */}
<GroupTitle
title={__('Max Discount Amount', 'formipay')}
icon="fa fa-calculator"
/>
{getGlobalCurrencies().map((currency) => {
const symbol = getCurrencySymbol(currency.currency);
const currencyCode = getCurrencyCode(currency.currency);
const flag = getFlagByCurrency(currency.currency);
const step = currency.decimal_digits > 0 ? 1 / (currency.decimal_digits * 10) : 1;
return (
<Field
key={`max_${currencyCode}`}
label={
<span>
{flag && <img src={flag} alt="" width="18" style={{ verticalAlign: 'middle', marginRight: '4px' }} />}
{__('Max Amount in', 'formipay')} {symbol}
</span>
}
description={__('Leave empty to not limit the max discount amount.', 'formipay')}
>
<Input
type="number"
min="0"
step={step}
placeholder={__('Enter Max Amount...', 'formipay')}
value={formData.max_amounts[symbol] || ''}
onChange={(e) => handleChange('max_amounts', {
...formData.max_amounts,
[symbol]: e.target.value
})}
/>
</Field>
);
})}
{/* Rules Section */}
<GroupTitle title={__('Rules', 'formipay')} icon="fa fa-list" />
<Field
label={__('Case Sensitive', 'formipay')}
description={__('If activated, coupon codes must be entered with the exact capitalization.', 'formipay')}
>
<Checkbox
checked={formData.case_sensitive === 'on'}
onChange={(e) => handleChange('case_sensitive', e.target.checked ? 'on' : '')}
/>
</Field>
<Field
label={__('Free Shipping', 'formipay')}
description={__('Shipping cost will be free when this coupon is applied.', 'formipay')}
>
<Checkbox
checked={formData.free_shipping === 'on'}
onChange={(e) => handleChange('free_shipping', e.target.checked ? 'on' : '')}
/>
</Field>
{isFixed && (
<Field
label={__('Influenced by Quantity', 'formipay')}
description={__('Example: when buyer buys 4 items, 4 × discount amount will be applied.', 'formipay')}
>
<Checkbox
checked={formData.quantity_active === 'on'}
onChange={(e) => handleChange('quantity_active', e.target.checked ? 'on' : '')}
/>
</Field>
)}
</div>
);
}
if (tab.id === 'restriction') {
return (
<div className="formipay-tab-content">
<GroupTitle title={__('Restrictions', 'formipay')} icon="fa fa-lock" />
<Field
label={__('Usage Limit', 'formipay')}
description={__('Leave empty or 0 (zero) for unlimited usage.', 'formipay')}
>
<Input
type="number"
min="0"
value={formData.use_limit}
onChange={(e) => handleChange('use_limit', e.target.value)}
/>
</Field>
<Field
label={__('Date Limit', 'formipay')}
description={__('Last day the coupon can be used. Leave empty for no limit.', 'formipay')}
>
<Input
type="date"
value={formData.date_limit}
onChange={(e) => handleChange('date_limit', e.target.value)}
/>
</Field>
<Field
label={__('Forms', 'formipay')}
description={__('Only selected form(s) can use the coupon. Leave empty to apply to all forms.', 'formipay')}
>
<AutocompleteField
postType="formipay-form"
value={formData.forms}
onChange={(values) => handleChange('forms', values)}
/>
</Field>
<Field
label={__('Products', 'formipay')}
description={__('Only selected product(s) can use the coupon. Leave empty to apply to all products.', 'formipay')}
>
<AutocompleteField
postType="formipay-product"
value={formData.products}
onChange={(values) => handleChange('products', values)}
/>
</Field>
<Field
label={__('Customers', 'formipay')}
description={__('Only selected customer(s) can use the coupon. Leave empty to apply to all customers.', 'formipay')}
>
<AutocompleteField
postType="formipay-customer"
value={formData.customers}
onChange={(values) => handleChange('customers', values)}
/>
</Field>
</div>
);
}
return null;
}}
</TabPanel>
<div className="formipay-metabox-actions">
<Button
variant="primary"
onClick={handleSave}
disabled={saving}
>
{saving ? __('Saving...', 'formipay') : __('Save Coupon', 'formipay')}
</Button>
</div>
</MetaboxLayout>
</div>
);
}
// Simple Autocomplete Field Component
function AutocompleteField({ postType, value, onChange }) {
const [search, setSearch] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const selectedItems = value || [];
const handleSearch = async (query) => {
setSearch(query);
if (query.length < 2) {
setResults([]);
return;
}
setLoading(true);
try {
const response = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-autocomplete-search`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
post_type: postType,
search: query,
_wpnonce: window.formipayAdmin?.nonce || '',
}),
});
const result = await response.json();
if (result.success) {
setResults(result.data || []);
}
} catch (error) {
console.error('Autocomplete search failed:', error);
} finally {
setLoading(false);
}
};
const handleSelect = (item) => {
if (!selectedItems.includes(item.value)) {
onChange([...selectedItems, item.value]);
}
setSearch('');
setResults([]);
};
const handleRemove = (valueToRemove) => {
onChange(selectedItems.filter(v => v !== valueToRemove));
};
return (
<div className="formipay-autocomplete">
<div className="formipay-autocomplete-selected">
{selectedItems.map(val => (
<span key={val} className="formipay-autocomplete-tag">
{val}
<button
type="button"
className="formipay-autocomplete-remove"
onClick={() => handleRemove(val)}
>
×
</button>
</span>
))}
</div>
<div className="formipay-autocomplete-input-wrapper">
<Input
type="text"
value={search}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => setOpen(true)}
placeholder={__('Search...', 'formipay')}
/>
{loading && <span className="formipay-autocomplete-loading">...</span>}
</div>
{open && results.length > 0 && (
<div className="formipay-autocomplete-results">
{results.map(item => (
<div
key={item.value}
className="formipay-autocomplete-result"
onClick={() => handleSelect(item)}
>
{item.label}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -4,9 +4,12 @@
import { render } from '@wordpress/element';
import App from './components/App';
import CouponMetabox from './components/coupons/CouponMetabox';
import './components/coupons/CouponMetabox.css';
// Mount the React app to all available mount points
const mountApps = () => {
// Mount main admin app pages
const mountPoints = document.querySelectorAll('[data-formipay-mount]');
console.log('[Formipay] Mount points found:', mountPoints.length);
@@ -28,6 +31,30 @@ const mountApps = () => {
console.error('[Formipay] Failed to mount:', page, error);
}
});
// Mount metabox islands
const metaboxPoints = document.querySelectorAll('[data-formipay-metabox]');
console.log('[Formipay] Metabox points found:', metaboxPoints.length);
metaboxPoints.forEach((mountPoint) => {
const metaboxType = mountPoint.dataset.formipayMetabox;
const postId = parseInt(mountPoint.dataset.postId || '0');
console.log('[Formipay] Mounting metabox:', metaboxType, 'for post:', postId);
try {
if (metaboxType === 'coupon') {
render(
<CouponMetabox postId={postId} />,
mountPoint
);
console.log('[Formipay] Successfully mounted coupon metabox');
}
} catch (error) {
console.error('[Formipay] Failed to mount metabox:', metaboxType, error);
}
});
};
// Initialize when DOM is ready