feat: rewrite CouponMetabox with proper shadcn/ui
- Remove all WPCFTO wrapper components (MetaboxLayout, TabNav, TabPanel, GroupTitle, etc.) - Use shadcn Tabs with horizontal underline triggers - Use grid-cols-[1fr_2fr] for clean form field layout - Use shadcn Switch for toggle fields - Use shadcn Select for discount type dropdown - Replace inline Notice with toast notifications - Add Skeleton loading state - Add SearchableSelect with Badge chips for relation fields - Move save button to tab header bar - No custom CSS classes - all Tailwind utilities
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash', 'wp-primitives'), 'version' => '6bf4643f2ea15ecdf0b7');
|
<?php return array('dependencies' => array('react', 'react-dom', 'wp-components', 'wp-element', 'wp-i18n', 'wp-icons/build/arrow-left', 'wp-icons/build/bell', 'wp-icons/build/message', 'wp-icons/build/trash', 'wp-primitives'), 'version' => 'f5d52a62f3d00d92014a');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1298
node_modules/.package-lock.json
generated
vendored
1298
node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,49 +1,61 @@
|
|||||||
/**
|
/**
|
||||||
* Coupon Metabox - React metabox island for post.php editor
|
* Coupon Metabox - React metabox island for post.php editor
|
||||||
* Uses WPCFTO design system components
|
* Uses shadcn/ui components directly
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from '@wordpress/element';
|
import { useState, useEffect, useRef } from '@wordpress/element';
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
import { MetaboxLayout, TabNav, TabPanel, Field, Input, Checkbox, Textarea, Button, Notice, GroupTitle } from '../../design-system';
|
import { toast } from '@/lib/toast';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
// Currency helper functions
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
// Currency helpers
|
||||||
const getGlobalCurrencies = () => {
|
const getGlobalCurrencies = () => {
|
||||||
if (window.formipayGlobalCurrencies && Array.isArray(window.formipayGlobalCurrencies)) {
|
if (window.formipayGlobalCurrencies && Array.isArray(window.formipayGlobalCurrencies)) {
|
||||||
return window.formipayGlobalCurrencies;
|
return window.formipayGlobalCurrencies;
|
||||||
}
|
}
|
||||||
console.warn('[Formipay] Global currencies not available or not an array');
|
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrencySymbol = (currencyRaw) => {
|
const getCurrencySymbol = (raw) => {
|
||||||
if (!currencyRaw || typeof currencyRaw !== 'string') return '';
|
if (!raw || typeof raw !== 'string') return '';
|
||||||
const parts = currencyRaw.split(':::');
|
const parts = raw.split(':::');
|
||||||
return parts[1] || currencyRaw;
|
return parts[1] || raw;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrencyCode = (currencyRaw) => {
|
const getCurrencyCode = (raw) => {
|
||||||
if (!currencyRaw || typeof currencyRaw !== 'string') return '';
|
if (!raw || typeof raw !== 'string') return '';
|
||||||
const parts = currencyRaw.split(':::');
|
return raw.split(':::')[0] || raw;
|
||||||
return parts[0] || currencyRaw;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFlagByCurrency = (currencyRaw) => {
|
const getFlagByCurrency = (raw) => {
|
||||||
if (!currencyRaw || typeof currencyRaw !== 'string') return '';
|
if (!raw || typeof raw !== 'string') return '';
|
||||||
if (window.formipayGetFlag && typeof window.formipayGetFlag === 'function') {
|
if (window.formipayGetFlag && typeof window.formipayGetFlag === 'function') {
|
||||||
return window.formipayGetFlag(currencyRaw);
|
return window.formipayGetFlag(raw);
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CouponMetabox({ postId }) {
|
export default function CouponMetabox({ postId }) {
|
||||||
const [activeTab, setActiveTab] = useState('rules');
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState(null);
|
|
||||||
const [globalCurrencies, setGlobalCurrencies] = useState([]);
|
const [globalCurrencies, setGlobalCurrencies] = useState([]);
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
active: 'on',
|
active: 'on',
|
||||||
type: 'percentage',
|
type: 'percentage',
|
||||||
@@ -60,80 +72,52 @@ export default function CouponMetabox({ postId }) {
|
|||||||
customers: [],
|
customers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'rules', label: __('Rules', 'formipay'), icon: 'fa fa-cog' },
|
|
||||||
{ id: 'restriction', label: __('Restrictions', 'formipay'), icon: 'fa fa-lock' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Load coupon data and global currencies
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load global currencies from window object
|
setGlobalCurrencies(getGlobalCurrencies());
|
||||||
const currencies = getGlobalCurrencies();
|
if (postId > 0) loadCouponData();
|
||||||
console.log('[Formipay Coupon] Global currencies:', currencies);
|
else setLoading(false);
|
||||||
setGlobalCurrencies(currencies);
|
|
||||||
|
|
||||||
if (postId > 0) {
|
|
||||||
loadCouponData();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
const loadCouponData = async () => {
|
const loadCouponData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-get-coupon`, {
|
const res = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-get-coupon`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({ id: postId, _wpnonce: window.formipayAdmin?.nonce || '' }),
|
||||||
id: postId,
|
|
||||||
_wpnonce: window.formipayAdmin?.nonce || '',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
const result = await res.json();
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = result.data;
|
const d = result.data;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
active: data.active || 'on',
|
active: d.active || 'on',
|
||||||
type: data.type || 'percentage',
|
type: d.type || 'percentage',
|
||||||
amount_percentage: data.amount_percentage || '',
|
amount_percentage: d.amount_percentage || '',
|
||||||
case_sensitive: data.case_sensitive || '',
|
case_sensitive: d.case_sensitive || '',
|
||||||
free_shipping: data.free_shipping || '',
|
free_shipping: d.free_shipping || '',
|
||||||
quantity_active: data.quantity_active || '',
|
quantity_active: d.quantity_active || '',
|
||||||
use_limit: data.use_limit || '',
|
use_limit: d.use_limit || '',
|
||||||
date_limit: data.date_limit || '',
|
date_limit: d.date_limit || '',
|
||||||
amounts_fixed: Array.isArray(data.amounts_fixed) ? data.amounts_fixed.reduce((acc, item) => {
|
amounts_fixed: Array.isArray(d.amounts_fixed) ? d.amounts_fixed.reduce((a, i) => { if (i?.symbol) a[i.symbol] = i.amount; return a; }, {}) : {},
|
||||||
if (item && item.symbol) acc[item.symbol] = item.amount;
|
max_amounts: Array.isArray(d.max_amounts) ? d.max_amounts.reduce((a, i) => { if (i?.symbol) a[i.symbol] = i.amount; return a; }, {}) : {},
|
||||||
return acc;
|
forms: Array.isArray(d.forms) ? d.forms : [],
|
||||||
}, {}) : {},
|
products: Array.isArray(d.products) ? d.products : [],
|
||||||
max_amounts: Array.isArray(data.max_amounts) ? data.max_amounts.reduce((acc, item) => {
|
customers: Array.isArray(d.customers) ? d.customers : [],
|
||||||
if (item && item.symbol) acc[item.symbol] = item.amount;
|
|
||||||
return acc;
|
|
||||||
}, {}) : {},
|
|
||||||
forms: Array.isArray(data.forms) ? data.forms : [],
|
|
||||||
products: Array.isArray(data.products) ? data.products : [],
|
|
||||||
customers: Array.isArray(data.customers) ? data.customers : [],
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Failed to load coupon:', error);
|
console.error('Failed to load coupon:', e);
|
||||||
setMessage({ type: 'error', text: __('Failed to load coupon data.', 'formipay') });
|
toast.error(__('Failed to load coupon data.', 'formipay'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
const set = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: postId,
|
id: postId,
|
||||||
@@ -141,34 +125,23 @@ export default function CouponMetabox({ postId }) {
|
|||||||
_wpnonce: window.formipayAdmin?.nonce || '',
|
_wpnonce: window.formipayAdmin?.nonce || '',
|
||||||
...formData,
|
...formData,
|
||||||
});
|
});
|
||||||
|
Object.entries(formData.amounts_fixed).forEach(([s, a]) => params.append(`amount_fixed_${s}`, a));
|
||||||
|
Object.entries(formData.max_amounts).forEach(([s, a]) => params.append(`max_amount_${s}`, a));
|
||||||
|
|
||||||
// Add fixed amounts
|
const res = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-save-coupon`, {
|
||||||
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',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: params,
|
body: params,
|
||||||
});
|
});
|
||||||
|
const result = await res.json();
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setMessage({ type: 'success', text: result.data.message || __('Coupon saved successfully.', 'formipay') });
|
toast.success(result.data.message || __('Coupon saved.', 'formipay'));
|
||||||
} else {
|
} else {
|
||||||
setMessage({ type: 'error', text: result.data?.message || __('Failed to save coupon.', 'formipay') });
|
toast.error(result.data?.message || __('Failed to save coupon.', 'formipay'));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('Failed to save coupon:', error);
|
toast.error(__('Failed to save coupon.', 'formipay'));
|
||||||
setMessage({ type: 'error', text: __('Failed to save coupon.', 'formipay') });
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -178,380 +151,341 @@ export default function CouponMetabox({ postId }) {
|
|||||||
const isFixed = formData.type === 'fixed';
|
const isFixed = formData.type === 'fixed';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="formipay-loading">{__('Loading...', 'formipay')}</div>;
|
return (
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="formipay-coupon-metabox">
|
<div className="formipay-design-system">
|
||||||
{message && (
|
<Tabs defaultValue="rules" className="w-full">
|
||||||
<Notice
|
<div className="flex items-center justify-between border-b mb-6">
|
||||||
type={message.type}
|
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||||
onDismiss={() => setMessage(null)}
|
<TabsTrigger
|
||||||
>
|
value="rules"
|
||||||
{message.text}
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5"
|
||||||
</Notice>
|
>
|
||||||
)}
|
{__('Rules', 'formipay')}
|
||||||
|
</TabsTrigger>
|
||||||
<MetaboxLayout
|
<TabsTrigger
|
||||||
tabs={tabs}
|
value="restriction"
|
||||||
activeTab={activeTab}
|
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2.5"
|
||||||
onTabChange={setActiveTab}
|
>
|
||||||
>
|
{__('Restrictions', 'formipay')}
|
||||||
<TabPanel tabs={tabs} activeTab={activeTab}>
|
</TabsTrigger>
|
||||||
{(tab) => {
|
</TabsList>
|
||||||
if (tab.id === 'rules') {
|
<Button onClick={handleSave} disabled={saving} size="sm">
|
||||||
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" />
|
|
||||||
|
|
||||||
{globalCurrencies.map((currency) => {
|
|
||||||
if (!currency || !currency.currency) return null;
|
|
||||||
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 || symbol} 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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{globalCurrencies.map((currency) => {
|
|
||||||
if (!currency || !currency.currency) return null;
|
|
||||||
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 || symbol}`}
|
|
||||||
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')}
|
{saving ? __('Saving...', 'formipay') : __('Save Coupon', 'formipay')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</MetaboxLayout>
|
|
||||||
|
{/* ── Rules Tab ── */}
|
||||||
|
<TabsContent value="rules" className="space-y-6 mt-0">
|
||||||
|
<SectionTitle>{__('General', 'formipay')}</SectionTitle>
|
||||||
|
|
||||||
|
<FormField id="active" label={__('Active', 'formipay')} description={__('Enable this coupon.', 'formipay')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={formData.active === 'on'}
|
||||||
|
onCheckedChange={(v) => set('active', v ? 'on' : '')}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{formData.active === 'on' ? __('Yes', 'formipay') : __('No', 'formipay')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="type" label={__('Discount Type', 'formipay')} description={__('Choose fixed or percentage discount.', 'formipay')} required>
|
||||||
|
<Select value={formData.type} onValueChange={(v) => set('type', v)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="percentage">{__('Percentage', 'formipay')}</SelectItem>
|
||||||
|
<SelectItem value="fixed">{__('Fixed Amount', 'formipay')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
{isPercentage && (
|
||||||
|
<FormField id="amount_percentage" label={__('Amount (%)', 'formipay')} description={__('Discount percentage value.', 'formipay')} required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0"
|
||||||
|
value={formData.amount_percentage}
|
||||||
|
onChange={(e) => set('amount_percentage', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFixed && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<SectionTitle>{__('Discount Amount', 'formipay')}</SectionTitle>
|
||||||
|
{globalCurrencies.map((c) => {
|
||||||
|
if (!c?.currency) return null;
|
||||||
|
const symbol = getCurrencySymbol(c.currency);
|
||||||
|
const code = getCurrencyCode(c.currency);
|
||||||
|
const flag = getFlagByCurrency(c.currency);
|
||||||
|
const step = c.decimal_digits > 0 ? 1 / (c.decimal_digits * 10) : 1;
|
||||||
|
return (
|
||||||
|
<FormField key={code} id={`amount_${code}`} label={
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{flag && <img src={flag} alt="" width="16" className="inline-block align-middle" />}
|
||||||
|
{__('Amount in', 'formipay')} {symbol}
|
||||||
|
</span>
|
||||||
|
} required>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step={step}
|
||||||
|
placeholder={__('Enter Amount...', 'formipay')}
|
||||||
|
value={formData.amounts_fixed[symbol] || ''}
|
||||||
|
onChange={(e) => set('amounts_fixed', { ...formData.amounts_fixed, [symbol]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<SectionTitle>{__('Max Discount', 'formipay')}</SectionTitle>
|
||||||
|
{globalCurrencies.map((c) => {
|
||||||
|
if (!c?.currency) return null;
|
||||||
|
const symbol = getCurrencySymbol(c.currency);
|
||||||
|
const code = getCurrencyCode(c.currency);
|
||||||
|
const flag = getFlagByCurrency(c.currency);
|
||||||
|
const step = c.decimal_digits > 0 ? 1 / (c.decimal_digits * 10) : 1;
|
||||||
|
return (
|
||||||
|
<FormField key={`max_${code}`} id={`max_${code}`} label={
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{flag && <img src={flag} alt="" width="16" className="inline-block align-middle" />}
|
||||||
|
{__('Max in', 'formipay')} {symbol}
|
||||||
|
</span>
|
||||||
|
} description={__('Leave empty for no limit.', 'formipay')}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step={step}
|
||||||
|
placeholder={__('No limit', 'formipay')}
|
||||||
|
value={formData.max_amounts[symbol] || ''}
|
||||||
|
onChange={(e) => set('max_amounts', { ...formData.max_amounts, [symbol]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<SectionTitle>{__('Rules', 'formipay')}</SectionTitle>
|
||||||
|
|
||||||
|
<SwitchField
|
||||||
|
id="case_sensitive"
|
||||||
|
label={__('Case Sensitive', 'formipay')}
|
||||||
|
description={__('Coupon codes must match exact capitalization.', 'formipay')}
|
||||||
|
checked={formData.case_sensitive === 'on'}
|
||||||
|
onChange={(v) => set('case_sensitive', v ? 'on' : '')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchField
|
||||||
|
id="free_shipping"
|
||||||
|
label={__('Free Shipping', 'formipay')}
|
||||||
|
description={__('Shipping cost will be free when this coupon is applied.', 'formipay')}
|
||||||
|
checked={formData.free_shipping === 'on'}
|
||||||
|
onChange={(v) => set('free_shipping', v ? 'on' : '')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isFixed && (
|
||||||
|
<SwitchField
|
||||||
|
id="quantity_active"
|
||||||
|
label={__('Influenced by Quantity', 'formipay')}
|
||||||
|
description={__('When buyer buys 4 items, 4 x discount amount will be applied.', 'formipay')}
|
||||||
|
checked={formData.quantity_active === 'on'}
|
||||||
|
onChange={(v) => set('quantity_active', v ? 'on' : '')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Restrictions Tab ── */}
|
||||||
|
<TabsContent value="restriction" className="space-y-6 mt-0">
|
||||||
|
<SectionTitle>{__('Usage Limits', 'formipay')}</SectionTitle>
|
||||||
|
|
||||||
|
<FormField id="use_limit" label={__('Usage Limit', 'formipay')} description={__('Leave empty or 0 for unlimited.', 'formipay')}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="0"
|
||||||
|
value={formData.use_limit}
|
||||||
|
onChange={(e) => set('use_limit', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="date_limit" label={__('Expiry Date', 'formipay')} description={__('Last day the coupon can be used.', 'formipay')}>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={formData.date_limit}
|
||||||
|
onChange={(e) => set('date_limit', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<SectionTitle>{__('Apply To', 'formipay')}</SectionTitle>
|
||||||
|
|
||||||
|
<FormField id="forms" label={__('Forms', 'formipay')} description={__('Only selected forms can use this coupon. Leave empty for all.', 'formipay')}>
|
||||||
|
<SearchableSelect
|
||||||
|
postType="formipay-form"
|
||||||
|
value={formData.forms}
|
||||||
|
onChange={(v) => set('forms', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="products" label={__('Products', 'formipay')} description={__('Only selected products can use this coupon. Leave empty for all.', 'formipay')}>
|
||||||
|
<SearchableSelect
|
||||||
|
postType="formipay-product"
|
||||||
|
value={formData.products}
|
||||||
|
onChange={(v) => set('products', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField id="customers" label={__('Customers', 'formipay')} description={__('Only selected customers can use this coupon. Leave empty for all.', 'formipay')}>
|
||||||
|
<SearchableSelect
|
||||||
|
postType="formipay-customer"
|
||||||
|
value={formData.customers}
|
||||||
|
onChange={(v) => set('customers', v)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple Autocomplete Field Component
|
/* ── Sub-components ── */
|
||||||
function AutocompleteField({ postType, value, onChange }) {
|
|
||||||
|
function SectionTitle({ children }) {
|
||||||
|
return <h3 className="text-sm font-medium text-muted-foreground tracking-wide uppercase">{children}</h3>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({ id, label, description, required, children }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_2fr] gap-4 items-start py-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={id} className={cn(required && "after:content-['*'] after:ml-0.5 after:text-destructive")}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SwitchField({ id, label, description, checked, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor={id} className="text-sm">{label}</Label>
|
||||||
|
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchableSelect({ postType, value = [], onChange }) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapperRef = useRef(null);
|
||||||
|
|
||||||
const selectedItems = value || [];
|
const selected = Array.isArray(value) ? value : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSearch = async (query) => {
|
const handleSearch = async (query) => {
|
||||||
setSearch(query);
|
setSearch(query);
|
||||||
if (query.length < 2) {
|
if (query.length < 2) { setResults([]); return; }
|
||||||
setResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-autocomplete-search`, {
|
const res = await fetch(`${window.formipayAdmin?.ajaxUrl || '/wp-admin/admin-ajax.php'}?action=formipay-autocomplete-search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({ post_type: postType, search: query, _wpnonce: window.formipayAdmin?.nonce || '' }),
|
||||||
post_type: postType,
|
|
||||||
search: query,
|
|
||||||
_wpnonce: window.formipayAdmin?.nonce || '',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
const result = await res.json();
|
||||||
const result = await response.json();
|
if (result.success) setResults(result.data || []);
|
||||||
if (result.success) {
|
} catch (e) {
|
||||||
setResults(result.data || []);
|
console.error('Search failed:', e);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Autocomplete search failed:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (item) => {
|
const handleSelect = (item) => {
|
||||||
if (!selectedItems.includes(item.value)) {
|
if (!selected.includes(item.value)) onChange([...selected, item.value]);
|
||||||
onChange([...selectedItems, item.value]);
|
|
||||||
}
|
|
||||||
setSearch('');
|
setSearch('');
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = (valueToRemove) => {
|
const handleRemove = (val) => onChange(selected.filter(v => v !== val));
|
||||||
onChange(selectedItems.filter(v => v !== valueToRemove));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="formipay-autocomplete">
|
<div ref={wrapperRef} className="space-y-2">
|
||||||
<div className="formipay-autocomplete-selected">
|
{selected.length > 0 && (
|
||||||
{selectedItems.map(val => (
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<span key={val} className="formipay-autocomplete-tag">
|
{selected.map((val) => {
|
||||||
{val}
|
const item = results.find(r => r.value === val);
|
||||||
<button
|
return (
|
||||||
type="button"
|
<Badge key={val} variant="secondary" className="gap-1 pr-1">
|
||||||
className="formipay-autocomplete-remove"
|
{item?.label || `#${val}`}
|
||||||
onClick={() => handleRemove(val)}
|
<button type="button" className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" onClick={() => handleRemove(val)}>
|
||||||
>
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||||
×
|
</button>
|
||||||
</button>
|
</Badge>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
placeholder={__('Search...', 'formipay')}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => { handleSearch(e.target.value); setOpen(true); }}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
/>
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<div className="absolute z-50 top-full mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md overflow-hidden">
|
||||||
|
{results.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className="px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user