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
|
||||
* 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 { 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 = () => {
|
||||
if (window.formipayGlobalCurrencies && Array.isArray(window.formipayGlobalCurrencies)) {
|
||||
return window.formipayGlobalCurrencies;
|
||||
}
|
||||
console.warn('[Formipay] Global currencies not available or not an array');
|
||||
return [];
|
||||
};
|
||||
|
||||
const getCurrencySymbol = (currencyRaw) => {
|
||||
if (!currencyRaw || typeof currencyRaw !== 'string') return '';
|
||||
const parts = currencyRaw.split(':::');
|
||||
return parts[1] || currencyRaw;
|
||||
const getCurrencySymbol = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
const parts = raw.split(':::');
|
||||
return parts[1] || raw;
|
||||
};
|
||||
|
||||
const getCurrencyCode = (currencyRaw) => {
|
||||
if (!currencyRaw || typeof currencyRaw !== 'string') return '';
|
||||
const parts = currencyRaw.split(':::');
|
||||
return parts[0] || currencyRaw;
|
||||
const getCurrencyCode = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
return raw.split(':::')[0] || raw;
|
||||
};
|
||||
|
||||
const getFlagByCurrency = (currencyRaw) => {
|
||||
if (!currencyRaw || typeof currencyRaw !== 'string') return '';
|
||||
const getFlagByCurrency = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return '';
|
||||
if (window.formipayGetFlag && typeof window.formipayGetFlag === 'function') {
|
||||
return window.formipayGetFlag(currencyRaw);
|
||||
return window.formipayGetFlag(raw);
|
||||
}
|
||||
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);
|
||||
const [globalCurrencies, setGlobalCurrencies] = useState([]);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
active: 'on',
|
||||
type: 'percentage',
|
||||
@@ -60,80 +72,52 @@ export default function CouponMetabox({ postId }) {
|
||||
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(() => {
|
||||
// Load global currencies from window object
|
||||
const currencies = getGlobalCurrencies();
|
||||
console.log('[Formipay Coupon] Global currencies:', currencies);
|
||||
setGlobalCurrencies(currencies);
|
||||
|
||||
if (postId > 0) {
|
||||
loadCouponData();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
setGlobalCurrencies(getGlobalCurrencies());
|
||||
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`, {
|
||||
const res = 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 || '',
|
||||
}),
|
||||
body: new URLSearchParams({ id: postId, _wpnonce: window.formipayAdmin?.nonce || '' }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
const data = result.data;
|
||||
const d = 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: Array.isArray(data.amounts_fixed) ? data.amounts_fixed.reduce((acc, item) => {
|
||||
if (item && item.symbol) acc[item.symbol] = item.amount;
|
||||
return acc;
|
||||
}, {}) : {},
|
||||
max_amounts: Array.isArray(data.max_amounts) ? data.max_amounts.reduce((acc, item) => {
|
||||
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 : [],
|
||||
active: d.active || 'on',
|
||||
type: d.type || 'percentage',
|
||||
amount_percentage: d.amount_percentage || '',
|
||||
case_sensitive: d.case_sensitive || '',
|
||||
free_shipping: d.free_shipping || '',
|
||||
quantity_active: d.quantity_active || '',
|
||||
use_limit: d.use_limit || '',
|
||||
date_limit: d.date_limit || '',
|
||||
amounts_fixed: Array.isArray(d.amounts_fixed) ? d.amounts_fixed.reduce((a, i) => { if (i?.symbol) a[i.symbol] = i.amount; return a; }, {}) : {},
|
||||
max_amounts: Array.isArray(d.max_amounts) ? d.max_amounts.reduce((a, i) => { if (i?.symbol) a[i.symbol] = i.amount; return a; }, {}) : {},
|
||||
forms: Array.isArray(d.forms) ? d.forms : [],
|
||||
products: Array.isArray(d.products) ? d.products : [],
|
||||
customers: Array.isArray(d.customers) ? d.customers : [],
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load coupon:', error);
|
||||
setMessage({ type: 'error', text: __('Failed to load coupon data.', 'formipay') });
|
||||
} catch (e) {
|
||||
console.error('Failed to load coupon:', e);
|
||||
toast.error(__('Failed to load coupon data.', 'formipay'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
const set = (field, value) => setFormData(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
id: postId,
|
||||
@@ -141,34 +125,23 @@ export default function CouponMetabox({ postId }) {
|
||||
_wpnonce: window.formipayAdmin?.nonce || '',
|
||||
...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
|
||||
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`, {
|
||||
const res = 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();
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: result.data.message || __('Coupon saved successfully.', 'formipay') });
|
||||
toast.success(result.data.message || __('Coupon saved.', 'formipay'));
|
||||
} 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) {
|
||||
console.error('Failed to save coupon:', error);
|
||||
setMessage({ type: 'error', text: __('Failed to save coupon.', 'formipay') });
|
||||
} catch (e) {
|
||||
toast.error(__('Failed to save coupon.', 'formipay'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -178,373 +151,333 @@ export default function CouponMetabox({ postId }) {
|
||||
const isFixed = formData.type === 'fixed';
|
||||
|
||||
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 (
|
||||
<div className="formipay-coupon-metabox">
|
||||
{message && (
|
||||
<Notice
|
||||
type={message.type}
|
||||
onDismiss={() => setMessage(null)}
|
||||
<div className="formipay-design-system">
|
||||
<Tabs defaultValue="rules" className="w-full">
|
||||
<div className="flex items-center justify-between border-b mb-6">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="rules"
|
||||
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"
|
||||
>
|
||||
{message.text}
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<MetaboxLayout
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
{__('Rules', 'formipay')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="restriction"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{__('Restrictions', 'formipay')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button onClick={handleSave} disabled={saving} size="sm">
|
||||
{saving ? __('Saving...', 'formipay') : __('Save Coupon', 'formipay')}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* ── 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 && (
|
||||
<Field
|
||||
label={__('Amount', 'formipay')}
|
||||
description={__('Discount percentage.', 'formipay')}
|
||||
required
|
||||
>
|
||||
<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) => handleChange('amount_percentage', e.target.value)}
|
||||
onChange={(e) => set('amount_percentage', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{/* 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;
|
||||
|
||||
<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 (
|
||||
<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' }} />}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="formipay-field-content">
|
||||
} required>
|
||||
<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
|
||||
})}
|
||||
onChange={(e) => set('amounts_fixed', { ...formData.amounts_fixed, [symbol]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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;
|
||||
|
||||
<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 (
|
||||
<Field
|
||||
key={`max_${currencyCode || symbol}`}
|
||||
label={
|
||||
<span>
|
||||
{flag && <img src={flag} alt="" width="18" style={{ verticalAlign: 'middle', marginRight: '4px' }} />}
|
||||
{__('Max Amount in', 'formipay')} {symbol}
|
||||
<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 to not limit the max discount amount.', 'formipay')}
|
||||
>
|
||||
} description={__('Leave empty for no limit.', 'formipay')}>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step={step}
|
||||
placeholder={__('Enter Max Amount...', 'formipay')}
|
||||
placeholder={__('No limit', 'formipay')}
|
||||
value={formData.max_amounts[symbol] || ''}
|
||||
onChange={(e) => handleChange('max_amounts', {
|
||||
...formData.max_amounts,
|
||||
[symbol]: e.target.value
|
||||
})}
|
||||
onChange={(e) => set('max_amounts', { ...formData.max_amounts, [symbol]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rules Section */}
|
||||
<GroupTitle title={__('Rules', 'formipay')} icon="fa fa-list" />
|
||||
<Separator />
|
||||
<SectionTitle>{__('Rules', 'formipay')}</SectionTitle>
|
||||
|
||||
<Field
|
||||
<SwitchField
|
||||
id="case_sensitive"
|
||||
label={__('Case Sensitive', 'formipay')}
|
||||
description={__('If activated, coupon codes must be entered with the exact capitalization.', 'formipay')}
|
||||
>
|
||||
<Checkbox
|
||||
description={__('Coupon codes must match exact capitalization.', 'formipay')}
|
||||
checked={formData.case_sensitive === 'on'}
|
||||
onChange={(e) => handleChange('case_sensitive', e.target.checked ? 'on' : '')}
|
||||
onChange={(v) => set('case_sensitive', v ? 'on' : '')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
<SwitchField
|
||||
id="free_shipping"
|
||||
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' : '')}
|
||||
onChange={(v) => set('free_shipping', v ? 'on' : '')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isFixed && (
|
||||
<Field
|
||||
<SwitchField
|
||||
id="quantity_active"
|
||||
label={__('Influenced by Quantity', 'formipay')}
|
||||
description={__('Example: when buyer buys 4 items, 4 × discount amount will be applied.', 'formipay')}
|
||||
>
|
||||
<Checkbox
|
||||
description={__('When buyer buys 4 items, 4 x discount amount will be applied.', 'formipay')}
|
||||
checked={formData.quantity_active === 'on'}
|
||||
onChange={(e) => handleChange('quantity_active', e.target.checked ? 'on' : '')}
|
||||
onChange={(v) => set('quantity_active', v ? 'on' : '')}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</TabsContent>
|
||||
|
||||
if (tab.id === 'restriction') {
|
||||
return (
|
||||
<div className="formipay-tab-content">
|
||||
<GroupTitle title={__('Restrictions', 'formipay')} icon="fa fa-lock" />
|
||||
{/* ── Restrictions Tab ── */}
|
||||
<TabsContent value="restriction" className="space-y-6 mt-0">
|
||||
<SectionTitle>{__('Usage Limits', 'formipay')}</SectionTitle>
|
||||
|
||||
<Field
|
||||
label={__('Usage Limit', 'formipay')}
|
||||
description={__('Leave empty or 0 (zero) for unlimited usage.', 'formipay')}
|
||||
>
|
||||
<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) => handleChange('use_limit', e.target.value)}
|
||||
onChange={(e) => set('use_limit', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field
|
||||
label={__('Date Limit', 'formipay')}
|
||||
description={__('Last day the coupon can be used. Leave empty for no limit.', 'formipay')}
|
||||
>
|
||||
<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) => handleChange('date_limit', e.target.value)}
|
||||
onChange={(e) => set('date_limit', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field
|
||||
label={__('Forms', 'formipay')}
|
||||
description={__('Only selected form(s) can use the coupon. Leave empty to apply to all forms.', 'formipay')}
|
||||
>
|
||||
<AutocompleteField
|
||||
<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={(values) => handleChange('forms', values)}
|
||||
onChange={(v) => set('forms', v)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field
|
||||
label={__('Products', 'formipay')}
|
||||
description={__('Only selected product(s) can use the coupon. Leave empty to apply to all products.', 'formipay')}
|
||||
>
|
||||
<AutocompleteField
|
||||
<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={(values) => handleChange('products', values)}
|
||||
onChange={(v) => set('products', v)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<Field
|
||||
label={__('Customers', 'formipay')}
|
||||
description={__('Only selected customer(s) can use the coupon. Leave empty to apply to all customers.', 'formipay')}
|
||||
>
|
||||
<AutocompleteField
|
||||
<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={(values) => handleChange('customers', values)}
|
||||
onChange={(v) => set('customers', v)}
|
||||
/>
|
||||
</Field>
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
</TabPanel>
|
||||
/* ── Sub-components ── */
|
||||
|
||||
<div className="formipay-metabox-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? __('Saving...', 'formipay') : __('Save Coupon', 'formipay')}
|
||||
</Button>
|
||||
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>
|
||||
</MetaboxLayout>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple Autocomplete Field Component
|
||||
function AutocompleteField({ postType, value, onChange }) {
|
||||
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 [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = 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) => {
|
||||
setSearch(query);
|
||||
if (query.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
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`, {
|
||||
const res = 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 || '',
|
||||
}),
|
||||
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);
|
||||
const result = await res.json();
|
||||
if (result.success) setResults(result.data || []);
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (item) => {
|
||||
if (!selectedItems.includes(item.value)) {
|
||||
onChange([...selectedItems, item.value]);
|
||||
}
|
||||
if (!selected.includes(item.value)) onChange([...selected, item.value]);
|
||||
setSearch('');
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleRemove = (valueToRemove) => {
|
||||
onChange(selectedItems.filter(v => v !== valueToRemove));
|
||||
};
|
||||
const handleRemove = (val) => onChange(selected.filter(v => v !== val));
|
||||
|
||||
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)}
|
||||
>
|
||||
×
|
||||
<div ref={wrapperRef} className="space-y-2">
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((val) => {
|
||||
const item = results.find(r => r.value === val);
|
||||
return (
|
||||
<Badge key={val} variant="secondary" className="gap-1 pr-1">
|
||||
{item?.label || `#${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>
|
||||
</span>
|
||||
))}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="formipay-autocomplete-input-wrapper">
|
||||
)}
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={__('Search...', 'formipay')}
|
||||
value={search}
|
||||
onChange={(e) => { handleSearch(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
/>
|
||||
{loading && <span className="formipay-autocomplete-loading">...</span>}
|
||||
</div>
|
||||
{open && results.length > 0 && (
|
||||
<div className="formipay-autocomplete-results">
|
||||
{results.map(item => (
|
||||
<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="formipay-autocomplete-result"
|
||||
className="px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -553,5 +486,6 @@ function AutocompleteField({ postType, value, onChange }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user