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:
@@ -18,9 +18,9 @@ import ProductEdit from '@/routes/Products/Edit';
|
||||
import ProductCategories from '@/routes/Products/Categories';
|
||||
import ProductTags from '@/routes/Products/Tags';
|
||||
import ProductAttributes from '@/routes/Products/Attributes';
|
||||
import CouponsIndex from '@/routes/Coupons';
|
||||
import CouponNew from '@/routes/Coupons/New';
|
||||
import CouponEdit from '@/routes/Coupons/Edit';
|
||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||
import CustomersIndex from '@/routes/Customers';
|
||||
import CustomerNew from '@/routes/Customers/New';
|
||||
import CustomerEdit from '@/routes/Customers/Edit';
|
||||
@@ -250,7 +250,6 @@ import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter';
|
||||
import EmailTemplates from '@/routes/Marketing/EmailTemplates';
|
||||
import MorePage from '@/routes/More';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
@@ -515,10 +514,13 @@ function AppRoutes() {
|
||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
||||
|
||||
{/* Coupons */}
|
||||
{/* Coupons (under Marketing) */}
|
||||
<Route path="/coupons" element={<CouponsIndex />} />
|
||||
<Route path="/coupons/new" element={<CouponNew />} />
|
||||
<Route path="/coupons/:id/edit" element={<CouponEdit />} />
|
||||
<Route path="/marketing/coupons" element={<CouponsIndex />} />
|
||||
<Route path="/marketing/coupons/new" element={<CouponNew />} />
|
||||
<Route path="/marketing/coupons/:id/edit" element={<CouponEdit />} />
|
||||
|
||||
{/* Customers */}
|
||||
<Route path="/customers" element={<CustomersIndex />} />
|
||||
@@ -565,7 +567,6 @@ function AppRoutes() {
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterSubscribers />} />
|
||||
<Route path="/marketing/newsletter/template/:template" element={<EmailTemplates />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{addonRoutes.map((route: any) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -105,6 +105,7 @@ export default function NotificationEvents() {
|
||||
const orderEvents = eventsData?.orders || [];
|
||||
const productEvents = eventsData?.products || [];
|
||||
const customerEvents = eventsData?.customers || [];
|
||||
const marketingEvents = eventsData?.marketing || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -340,6 +341,77 @@ export default function NotificationEvents() {
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Marketing Events */}
|
||||
{marketingEvents.length > 0 && (
|
||||
<SettingsCard
|
||||
title={__('Marketing Events')}
|
||||
description={__('Notifications about newsletter and marketing activities')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{marketingEvents.map((event: NotificationEvent) => (
|
||||
<div key={event.id} className="pb-6 border-b last:border-0">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-sm">{event.label}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{event.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{channels?.map((channel: NotificationChannel) => {
|
||||
const channelEnabled = event.channels?.[channel.id]?.enabled || false;
|
||||
const recipient = event.channels?.[channel.id]?.recipient || 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border bg-card"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${channelEnabled ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'}`}>
|
||||
{getChannelIcon(channel.id)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{channel.label}</span>
|
||||
{channel.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{__('Built-in')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{channelEnabled && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{__('Send to')}: {recipient === 'admin' ? __('Admin') : recipient === 'customer' ? __('Customer') : __('Both')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{channelEnabled && channel.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openTemplateEditor(event.id, channel.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Switch
|
||||
checked={channelEnabled}
|
||||
onCheckedChange={() => toggleChannel(event.id, channel.id, channelEnabled)}
|
||||
disabled={!channel.enabled || updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user