Files
WooNooW/admin-spa/src/routes/Marketing/Coupons/CouponForm.tsx
Dwindi Ramadhana 0b2c8a56d6 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
2025-12-26 10:59:48 +07:00

413 lines
15 KiB
TypeScript

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