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>
|
||||
);
|
||||
}
|
||||
113
admin-spa/src/routes/Marketing/Coupons/Edit.tsx
Normal file
113
admin-spa/src/routes/Marketing/Coupons/Edit.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { CouponsApi, type CouponFormData } from '@/lib/api/coupons';
|
||||
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import CouponForm from './CouponForm';
|
||||
|
||||
export default function CouponEdit() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const couponId = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
|
||||
// Hide FAB on edit page
|
||||
useFABConfig('none');
|
||||
|
||||
// Fetch coupon
|
||||
const { data: coupon, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['coupon', couponId],
|
||||
queryFn: () => CouponsApi.get(couponId),
|
||||
enabled: !!couponId,
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: CouponFormData) => CouponsApi.update(couponId, data),
|
||||
onSuccess: (updatedCoupon) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coupons'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['coupon', couponId] });
|
||||
showSuccessToast(__('Coupon updated successfully'), `${__('Coupon')} ${updatedCoupon.code} ${__('updated')}`);
|
||||
navigate('/coupons');
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Smart back handler: go back in history if available, otherwise fallback to /coupons
|
||||
const handleBack = () => {
|
||||
if (window.history.state?.idx > 0) {
|
||||
navigate(-1); // Go back in history
|
||||
} else {
|
||||
navigate('/coupons'); // Fallback to coupons index
|
||||
}
|
||||
};
|
||||
|
||||
// Set contextual header
|
||||
useEffect(() => {
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={handleBack}>
|
||||
{__('Back')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
disabled={updateMutation.isPending || isLoading}
|
||||
>
|
||||
{updateMutation.isPending ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const title = coupon ? `${__('Edit Coupon')}: ${coupon.code}` : __('Edit Coupon');
|
||||
setPageHeader(title, actions);
|
||||
return () => clearPageHeader();
|
||||
}, [coupon, updateMutation.isPending, isLoading, setPageHeader, clearPageHeader, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message={__('Loading coupon...')} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorCard
|
||||
title={__('Failed to load coupon')}
|
||||
message={getPageLoadErrorMessage(error)}
|
||||
onRetry={() => queryClient.invalidateQueries({ queryKey: ['coupon', couponId] })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!coupon) {
|
||||
return (
|
||||
<ErrorCard
|
||||
title={__('Coupon not found')}
|
||||
message={__('The requested coupon could not be found')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CouponForm
|
||||
mode="edit"
|
||||
initial={coupon}
|
||||
onSubmit={async (data) => {
|
||||
await updateMutation.mutateAsync(data);
|
||||
}}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
admin-spa/src/routes/Marketing/Coupons/New.tsx
Normal file
67
admin-spa/src/routes/Marketing/Coupons/New.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { CouponsApi, type CouponFormData } from '@/lib/api/coupons';
|
||||
import { showErrorToast, showSuccessToast } from '@/lib/errorHandling';
|
||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import CouponForm from './CouponForm';
|
||||
|
||||
export default function CouponNew() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||
|
||||
// Hide FAB on create page
|
||||
useFABConfig('none');
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CouponFormData) => CouponsApi.create(data),
|
||||
onSuccess: (coupon) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coupons'] });
|
||||
showSuccessToast(__('Coupon created successfully'), `${__('Coupon')} ${coupon.code} ${__('created')}`);
|
||||
navigate('/coupons');
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Set contextual header
|
||||
useEffect(() => {
|
||||
const actions = (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate('/coupons')}>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => formRef.current?.requestSubmit()}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? __('Creating...') : __('Create')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
setPageHeader(__('New Coupon'), actions);
|
||||
return () => clearPageHeader();
|
||||
}, [createMutation.isPending, setPageHeader, clearPageHeader, navigate]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CouponForm
|
||||
mode="create"
|
||||
onSubmit={async (data) => {
|
||||
await createMutation.mutateAsync(data);
|
||||
}}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
admin-spa/src/routes/Marketing/Coupons/components/CouponCard.tsx
Normal file
104
admin-spa/src/routes/Marketing/Coupons/components/CouponCard.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronRight, Tag } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { Coupon } from '@/lib/api/coupons';
|
||||
|
||||
interface CouponCardProps {
|
||||
coupon: Coupon;
|
||||
selected?: boolean;
|
||||
onSelect?: (id: number) => void;
|
||||
}
|
||||
|
||||
export function CouponCard({ coupon, selected, onSelect }: CouponCardProps) {
|
||||
// Format discount type
|
||||
const formatDiscountType = (type: string) => {
|
||||
switch (type) {
|
||||
case 'percent':
|
||||
return __('Percentage');
|
||||
case 'fixed_cart':
|
||||
return __('Fixed Cart');
|
||||
case 'fixed_product':
|
||||
return __('Fixed Product');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
// Format amount
|
||||
const formatAmount = () => {
|
||||
if (coupon.discount_type === 'percent') {
|
||||
return `${coupon.amount}%`;
|
||||
}
|
||||
return `Rp${coupon.amount.toLocaleString('id-ID')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/coupons/${coupon.id}/edit`}
|
||||
className="block bg-card border border-border rounded-xl p-3 hover:bg-accent/50 transition-colors active:scale-[0.98] active:transition-transform shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Checkbox */}
|
||||
{onSelect && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(coupon.id);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
aria-label={__('Select coupon')}
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Line 1: Code with Badge */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-shrink-0 p-2 rounded-xl bg-primary/10 text-primary flex items-center justify-center font-bold text-base">
|
||||
<Tag className="w-4 h-4 mr-1" />
|
||||
{coupon.code}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatDiscountType(coupon.discount_type)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Line 2: Description */}
|
||||
{coupon.description && (
|
||||
<div className="text-sm text-muted-foreground truncate mb-2">
|
||||
{coupon.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line 3: Usage & Expiry */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-2">
|
||||
<span>
|
||||
{__('Usage')}: {coupon.usage_count} / {coupon.usage_limit || '∞'}
|
||||
</span>
|
||||
{coupon.date_expires && (
|
||||
<span>
|
||||
{__('Expires')}: {new Date(coupon.date_expires).toLocaleDateString('id-ID')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Line 4: Amount */}
|
||||
<div className="font-bold text-lg tabular-nums text-primary">
|
||||
{formatAmount()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface CouponFilterSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
filters: {
|
||||
discount_type: string;
|
||||
};
|
||||
onFiltersChange: (filters: { discount_type: string }) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function CouponFilterSheet({
|
||||
open,
|
||||
onClose,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
onReset,
|
||||
}: CouponFilterSheetProps) {
|
||||
const [localFilters, setLocalFilters] = useState(filters);
|
||||
|
||||
const handleApply = () => {
|
||||
onFiltersChange(localFilters);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setLocalFilters({ discount_type: '' });
|
||||
onReset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onClose}>
|
||||
<SheetContent side="bottom" className="h-[400px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{__('Filter Coupons')}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Discount Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Discount Type')}</Label>
|
||||
<Select
|
||||
value={localFilters.discount_type || 'all'}
|
||||
onValueChange={(value) =>
|
||||
setLocalFilters({ ...localFilters, discount_type: value === 'all' ? '' : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={__('All types')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{__('All types')}</SelectItem>
|
||||
<SelectItem value="percent">{__('Percentage')}</SelectItem>
|
||||
<SelectItem value="fixed_cart">{__('Fixed Cart')}</SelectItem>
|
||||
<SelectItem value="fixed_product">{__('Fixed Product')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t bg-background flex gap-3">
|
||||
<Button variant="outline" onClick={handleReset} className="flex-1">
|
||||
{__('Reset')}
|
||||
</Button>
|
||||
<Button onClick={handleApply} className="flex-1">
|
||||
{__('Apply Filters')}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
364
admin-spa/src/routes/Marketing/Coupons/index.tsx
Normal file
364
admin-spa/src/routes/Marketing/Coupons/index.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { CouponsApi, type Coupon } from '@/lib/api/coupons';
|
||||
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||
import { ErrorCard } from '@/components/ErrorCard';
|
||||
import { LoadingState } from '@/components/LoadingState';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, RefreshCw, Edit, Tag, Search, SlidersHorizontal } from 'lucide-react';
|
||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||
import { CouponFilterSheet } from './components/CouponFilterSheet';
|
||||
import { CouponCard } from './components/CouponCard';
|
||||
|
||||
export default function CouponsIndex() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [discountType, setDiscountType] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||
|
||||
// Configure FAB to navigate to new coupon page
|
||||
useFABConfig('coupons');
|
||||
|
||||
// Count active filters
|
||||
const activeFiltersCount = discountType && discountType !== 'all' ? 1 : 0;
|
||||
|
||||
// Fetch coupons
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['coupons', page, search, discountType],
|
||||
queryFn: () => CouponsApi.list({
|
||||
page,
|
||||
per_page: 20,
|
||||
search,
|
||||
discount_type: discountType && discountType !== 'all' ? discountType : undefined
|
||||
}),
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => CouponsApi.delete(id, false),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['coupons'] });
|
||||
showSuccessToast(__('Coupon deleted successfully'));
|
||||
setSelectedIds([]);
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorToast(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Bulk delete
|
||||
const handleBulkDelete = async () => {
|
||||
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
||||
|
||||
for (const id of selectedIds) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle selection
|
||||
const toggleSelection = (id: number) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle all
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === data?.coupons.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(data?.coupons.map(c => c.id) || []);
|
||||
}
|
||||
};
|
||||
|
||||
// Format discount type
|
||||
const formatDiscountType = (type: string) => {
|
||||
const types: Record<string, string> = {
|
||||
'percent': __('Percentage'),
|
||||
'fixed_cart': __('Fixed Cart'),
|
||||
'fixed_product': __('Fixed Product'),
|
||||
};
|
||||
return types[type] || type;
|
||||
};
|
||||
|
||||
// Format amount
|
||||
const formatAmount = (coupon: Coupon) => {
|
||||
if (coupon.discount_type === 'percent') {
|
||||
return `${coupon.amount}%`;
|
||||
}
|
||||
return `Rp${coupon.amount.toLocaleString('id-ID')}`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState message={__('Loading coupons...')} />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorCard
|
||||
title={__('Failed to load coupons')}
|
||||
message={getPageLoadErrorMessage(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const coupons = data?.coupons || [];
|
||||
const hasActiveFilters = search || (discountType && discountType !== 'all');
|
||||
|
||||
return (
|
||||
<div className="space-y-4 w-full pb-4">
|
||||
{/* Mobile: Search + Filter */}
|
||||
<div className="md:hidden">
|
||||
<div className="flex gap-2 items-center">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={__('Search coupons...')}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
onClick={() => setFilterSheetOpen(true)}
|
||||
className="relative flex-shrink-0 p-2.5 rounded-lg border border-border bg-background hover:bg-accent transition-colors"
|
||||
>
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-primary text-primary-foreground text-xs font-medium rounded-full flex items-center justify-center">
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Toolbar */}
|
||||
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||
|
||||
{/* Left: Bulk Actions */}
|
||||
<div className="flex gap-3">
|
||||
{/* Delete - Show only when items selected */}
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{__('Delete')} ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Refresh - Always visible (REQUIRED per SOP) */}
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{__('Refresh')}
|
||||
</button>
|
||||
|
||||
{/* New Coupon - Desktop only */}
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-2"
|
||||
onClick={() => navigate('/coupons/new')}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
{__('New Coupon')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: Filters */}
|
||||
<div className="flex gap-3 flex-wrap items-center">
|
||||
{/* Discount Type Filter */}
|
||||
<Select value={discountType || undefined} onValueChange={(value) => setDiscountType(value || '')}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={__('All types')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{__('All types')}</SelectItem>
|
||||
<SelectItem value="percent">{__('Percentage')}</SelectItem>
|
||||
<SelectItem value="fixed_cart">{__('Fixed Cart')}</SelectItem>
|
||||
<SelectItem value="fixed_product">{__('Fixed Product')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Search */}
|
||||
<Input
|
||||
placeholder={__('Search coupons...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
|
||||
{/* Reset Filters - Text link style per SOP */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground underline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setDiscountType('');
|
||||
}}
|
||||
>
|
||||
{__('Clear filters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden md:block rounded-lg border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr className="border-b">
|
||||
<th className="w-12 p-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.length === coupons.length && coupons.length > 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium">{__('Code')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Type')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Amount')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Usage')}</th>
|
||||
<th className="text-left p-3 font-medium">{__('Expires')}</th>
|
||||
<th className="text-center p-3 font-medium">{__('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{coupons.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-8 text-center text-muted-foreground">
|
||||
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
{hasActiveFilters ? __('No coupons found matching your filters') : __('No coupons yet')}
|
||||
{!hasActiveFilters && (
|
||||
<p className="text-sm mt-1">{__('Create your first coupon to get started')}</p>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
coupons.map((coupon) => (
|
||||
<tr key={coupon.id} className="border-b hover:bg-muted/30 last:border-0">
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(coupon.id)}
|
||||
onCheckedChange={() => toggleSelection(coupon.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link to={`/coupons/${coupon.id}/edit`} className="font-medium hover:underline">
|
||||
{coupon.code}
|
||||
</Link>
|
||||
{coupon.description && (
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{coupon.description}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{formatDiscountType(coupon.discount_type)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 font-medium">{formatAmount(coupon)}</td>
|
||||
<td className="p-3">
|
||||
<div className="text-sm">
|
||||
{coupon.usage_count} / {coupon.usage_limit || '∞'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{coupon.date_expires ? (
|
||||
<div className="text-sm">{new Date(coupon.date_expires).toLocaleDateString('id-ID')}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{__('No expiry')}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700"
|
||||
onClick={() => navigate(`/coupons/${coupon.id}/edit`)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
{__('Edit')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{coupons.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<Tag className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
{hasActiveFilters ? __('No coupons found matching your filters') : __('No coupons yet')}
|
||||
{!hasActiveFilters && (
|
||||
<p className="text-sm mt-1">{__('Create your first coupon to get started')}</p>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
coupons.map((coupon) => (
|
||||
<CouponCard
|
||||
key={coupon.id}
|
||||
coupon={coupon}
|
||||
selected={selectedIds.includes(coupon.id)}
|
||||
onSelect={toggleSelection}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{__('Page')} {page} {__('of')} {data.total_pages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
{__('Previous')}
|
||||
</button>
|
||||
<button
|
||||
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.total_pages}
|
||||
>
|
||||
{__('Next')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Filter Sheet */}
|
||||
<CouponFilterSheet
|
||||
open={filterSheetOpen}
|
||||
onClose={() => setFilterSheetOpen(false)}
|
||||
filters={{ discount_type: discountType }}
|
||||
onFiltersChange={(filters) => setDiscountType(filters.discount_type)}
|
||||
onReset={() => setDiscountType('')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
export default function EmailTemplates() {
|
||||
const navigate = useNavigate();
|
||||
const { template } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [subject, setSubject] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const { data: templateData, isLoading } = useQuery({
|
||||
queryKey: ['newsletter-template', template],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/newsletter/template/${template}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!template,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (templateData) {
|
||||
setSubject(templateData.subject || '');
|
||||
setContent(templateData.content || '');
|
||||
}
|
||||
}, [templateData]);
|
||||
|
||||
const saveTemplate = useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.post(`/newsletter/template/${template}`, {
|
||||
subject,
|
||||
content,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['newsletter-template'] });
|
||||
toast.success('Template saved successfully');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to save template');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
saveTemplate.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title={`Edit ${template === 'welcome' ? 'Welcome' : 'Confirmation'} Email Template`}
|
||||
description="Customize the email template sent to newsletter subscribers"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/marketing/newsletter')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Newsletter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsCard
|
||||
title="Email Template"
|
||||
description="Use variables like {site_name}, {email}, {unsubscribe_url}"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subject">Email Subject</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Welcome to {site_name} Newsletter!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content">Email Content</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={15}
|
||||
placeholder="Thank you for subscribing to our newsletter! You'll receive updates about our latest products and offers. Best regards, {site_name}"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Available variables: <code>{'{site_name}'}</code>, <code>{'{email}'}</code>, <code>{'{unsubscribe_url}'}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => navigate('/marketing/newsletter')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveTemplate.isPending}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveTemplate.isPending ? 'Saving...' : 'Save Template'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Preview"
|
||||
description="Preview how your email will look"
|
||||
>
|
||||
<div className="border rounded-lg p-6 bg-muted/50">
|
||||
<div className="mb-4">
|
||||
<strong>Subject:</strong> {subject.replace('{site_name}', 'Your Store')}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap">
|
||||
{content.replace('{site_name}', 'Your Store').replace('{email}', 'customer@example.com')}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default function NewsletterSubscribers() {
|
||||
|
||||
const deleteSubscriber = useMutation({
|
||||
mutationFn: async (email: string) => {
|
||||
await api.delete(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||
@@ -77,14 +77,14 @@ export default function NewsletterSubscribers() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Actions Bar */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by email..."
|
||||
placeholder="Filter subscribers..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
className="!pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -175,7 +175,7 @@ export default function NewsletterSubscribers() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/notifications/customer/newsletter_welcome/edit')}
|
||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_welcome&channel=email&recipient=customer')}
|
||||
>
|
||||
Edit Template
|
||||
</Button>
|
||||
@@ -189,7 +189,7 @@ export default function NewsletterSubscribers() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings/notifications/staff/newsletter_subscribed_admin/edit')}
|
||||
onClick={() => navigate('/settings/notifications/edit-template?event=newsletter_subscribed_admin&channel=email&recipient=staff')}
|
||||
>
|
||||
Edit Template
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user