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:
dwindown
2026-04-19 13:49:42 +07:00
parent c103e368be
commit 7ba92022d5
6 changed files with 1653 additions and 439 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>
); );
} }