feat: Newsletter system improvements and validation framework
- Fix: Marketing events now display in Staff notifications tab - Reorganize: Move Coupons to Marketing/Coupons for better organization - Add: Comprehensive email/phone validation with extensible filter hooks - Email validation with regex pattern (xxxx@xxxx.xx) - Phone validation with WhatsApp verification support - Filter hooks for external API integration (QuickEmailVerification, etc.) - Fix: Newsletter template routes now use centralized notification email builder - Add: Validation.php class for reusable validation logic - Add: VALIDATION_HOOKS.md documentation with integration examples - Add: NEWSLETTER_CAMPAIGN_PLAN.md architecture for future campaign system - Fix: API delete method call in Newsletter.tsx (delete -> del) - Remove: Duplicate EmailTemplates.tsx (using notification system instead) - Update: Newsletter controller to use centralized Validation class Breaking changes: - Coupons routes moved from /routes/Coupons to /routes/Marketing/Coupons - Legacy /coupons routes maintained for backward compatibility
This commit is contained in:
412
admin-spa/src/routes/Marketing/Coupons/CouponForm.tsx
Normal file
412
admin-spa/src/routes/Marketing/Coupons/CouponForm.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MultiSelect } from '@/components/ui/multi-select';
|
||||
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
||||
import { ProductsApi } from '@/lib/api';
|
||||
import { Settings, ShieldCheck, BarChart3 } from 'lucide-react';
|
||||
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
||||
|
||||
interface CouponFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
initial?: Coupon | null;
|
||||
onSubmit: (data: CouponFormData) => Promise<void> | void;
|
||||
formRef?: React.RefObject<HTMLFormElement>;
|
||||
hideSubmitButton?: boolean;
|
||||
}
|
||||
|
||||
export default function CouponForm({
|
||||
mode,
|
||||
initial,
|
||||
onSubmit,
|
||||
formRef,
|
||||
hideSubmitButton = false,
|
||||
}: CouponFormProps) {
|
||||
const [formData, setFormData] = useState<CouponFormData>({
|
||||
code: initial?.code || '',
|
||||
amount: initial?.amount || 0,
|
||||
discount_type: initial?.discount_type || 'percent',
|
||||
description: initial?.description || '',
|
||||
date_expires: initial?.date_expires || null,
|
||||
individual_use: initial?.individual_use || false,
|
||||
product_ids: initial?.product_ids || [],
|
||||
excluded_product_ids: initial?.excluded_product_ids || [],
|
||||
product_categories: initial?.product_categories || [],
|
||||
excluded_product_categories: initial?.excluded_product_categories || [],
|
||||
usage_limit: initial?.usage_limit || null,
|
||||
usage_limit_per_user: initial?.usage_limit_per_user || null,
|
||||
free_shipping: initial?.free_shipping || false,
|
||||
exclude_sale_items: initial?.exclude_sale_items || false,
|
||||
minimum_amount: initial?.minimum_amount || null,
|
||||
maximum_amount: initial?.maximum_amount || null,
|
||||
});
|
||||
|
||||
// Fetch products and categories
|
||||
const { data: productsData } = useQuery({
|
||||
queryKey: ['products-list'],
|
||||
queryFn: () => ProductsApi.list({ per_page: 100 }),
|
||||
});
|
||||
|
||||
const { data: categoriesData } = useQuery({
|
||||
queryKey: ['product-categories'],
|
||||
queryFn: () => ProductsApi.categories(),
|
||||
});
|
||||
|
||||
const products = (productsData as any)?.rows || [];
|
||||
const categories = categoriesData || [];
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof CouponFormData, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: __('General'), icon: <Settings className="w-4 h-4" /> },
|
||||
{ id: 'restrictions', label: __('Restrictions'), icon: <ShieldCheck className="w-4 h-4" /> },
|
||||
{ id: 'limits', label: __('Limits'), icon: <BarChart3 className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<VerticalTabForm tabs={tabs}>
|
||||
{/* General Settings */}
|
||||
<FormSection id="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('General')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Coupon Code */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">
|
||||
{__('Coupon code')} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={formData.code}
|
||||
onChange={(e) => updateField('code', e.target.value.toUpperCase())}
|
||||
placeholder={__('e.g., SUMMER2024')}
|
||||
required
|
||||
disabled={mode === 'edit'} // Can't change code after creation
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Unique code that customers will enter at checkout')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">{__('Description')}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateField('description', e.target.value)}
|
||||
placeholder={__('Optional description for internal use')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Discount Type */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discount_type">
|
||||
{__('Discount type')} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.discount_type}
|
||||
onValueChange={(value) => updateField('discount_type', value)}
|
||||
>
|
||||
<SelectTrigger id="discount_type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="percent">{__('Percentage discount')}</SelectItem>
|
||||
<SelectItem value="fixed_cart">{__('Fixed cart discount')}</SelectItem>
|
||||
<SelectItem value="fixed_product">{__('Fixed product discount')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
{__('Coupon amount')} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.amount}
|
||||
onChange={(e) => updateField('amount', parseFloat(e.target.value) || 0)}
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formData.discount_type === 'percent'
|
||||
? __('Enter percentage (e.g., 10 for 10%)')
|
||||
: __('Enter amount in Rupiah')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Expiry Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date_expires">{__('Expiry date')}</Label>
|
||||
<Input
|
||||
id="date_expires"
|
||||
type="date"
|
||||
value={formData.date_expires || ''}
|
||||
onChange={(e) => updateField('date_expires', e.target.value || null)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Leave empty for no expiry')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSection>
|
||||
|
||||
{/* Usage Restrictions */}
|
||||
<FormSection id="restrictions">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Usage restrictions')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Minimum Spend */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minimum_amount">{__('Minimum spend')}</Label>
|
||||
<Input
|
||||
id="minimum_amount"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.minimum_amount || ''}
|
||||
onChange={(e) => updateField('minimum_amount', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Minimum order amount required to use this coupon')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Maximum Spend */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maximum_amount">{__('Maximum spend')}</Label>
|
||||
<Input
|
||||
id="maximum_amount"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={formData.maximum_amount || ''}
|
||||
onChange={(e) => updateField('maximum_amount', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Maximum order amount allowed to use this coupon')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Products')}</Label>
|
||||
<MultiSelect
|
||||
options={products.map((p: any) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
}))}
|
||||
selected={(formData.product_ids || []).map(String)}
|
||||
onChange={(selected) => updateField('product_ids', selected.map(Number))}
|
||||
placeholder={__('Search for products...')}
|
||||
emptyMessage={__('No products found')}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Products that the coupon will be applied to, or leave blank for all products')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Exclude Products */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Exclude products')}</Label>
|
||||
<MultiSelect
|
||||
options={products.map((p: any) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
}))}
|
||||
selected={(formData.excluded_product_ids || []).map(String)}
|
||||
onChange={(selected) => updateField('excluded_product_ids', selected.map(Number))}
|
||||
placeholder={__('Search for products...')}
|
||||
emptyMessage={__('No products found')}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Products that the coupon will not be applied to')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Product Categories */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Product categories')}</Label>
|
||||
<MultiSelect
|
||||
options={categories.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
}))}
|
||||
selected={(formData.product_categories || []).map(String)}
|
||||
onChange={(selected) => updateField('product_categories', selected.map(Number))}
|
||||
placeholder={__('Any category')}
|
||||
emptyMessage={__('No categories found')}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Product categories that the coupon will be applied to, or leave blank for all categories')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Exclude Categories */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Exclude categories')}</Label>
|
||||
<MultiSelect
|
||||
options={categories.map((c: any) => ({
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
}))}
|
||||
selected={(formData.excluded_product_categories || []).map(String)}
|
||||
onChange={(selected) => updateField('excluded_product_categories', selected.map(Number))}
|
||||
placeholder={__('No categories')}
|
||||
emptyMessage={__('No categories found')}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('Product categories that the coupon will not be applied to')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Individual Use */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="individual_use"
|
||||
checked={formData.individual_use}
|
||||
onCheckedChange={(checked) => updateField('individual_use', checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="individual_use"
|
||||
className="text-sm cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{__('Individual use only')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{__('Check this box if the coupon cannot be used in conjunction with other coupons')}
|
||||
</p>
|
||||
|
||||
{/* Exclude Sale Items */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="exclude_sale_items"
|
||||
checked={formData.exclude_sale_items}
|
||||
onCheckedChange={(checked) => updateField('exclude_sale_items', checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="exclude_sale_items"
|
||||
className="text-sm cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{__('Exclude sale items')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{__('Check this box if the coupon should not apply to items on sale')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSection>
|
||||
|
||||
{/* Usage Limits */}
|
||||
<FormSection id="limits">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Usage limits')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Usage Limit */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage_limit">{__('Usage limit per coupon')}</Label>
|
||||
<Input
|
||||
id="usage_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.usage_limit || ''}
|
||||
onChange={(e) => updateField('usage_limit', e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder={__('Unlimited')}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('How many times this coupon can be used before it is void')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Usage Limit Per User */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage_limit_per_user">{__('Usage limit per user')}</Label>
|
||||
<Input
|
||||
id="usage_limit_per_user"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.usage_limit_per_user || ''}
|
||||
onChange={(e) => updateField('usage_limit_per_user', e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder={__('Unlimited')}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('How many times this coupon can be used by an individual user')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Free Shipping */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="free_shipping"
|
||||
checked={formData.free_shipping}
|
||||
onCheckedChange={(checked) => updateField('free_shipping', checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="free_shipping"
|
||||
className="text-sm cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{__('Allow free shipping')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{__('Check this box if the coupon grants free shipping')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSection>
|
||||
|
||||
{/* Submit Button (if not hidden) */}
|
||||
{!hideSubmitButton && (
|
||||
<div className="flex gap-3 mt-6">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting
|
||||
? __('Saving...')
|
||||
: mode === 'create'
|
||||
? __('Create Coupon')
|
||||
: __('Update Coupon')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</VerticalTabForm>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user