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:
Dwindi Ramadhana
2025-12-26 10:59:48 +07:00
parent 0b08ddefa1
commit 0b2c8a56d6
23 changed files with 1132 additions and 232 deletions

View File

@@ -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) => (

View File

@@ -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!&#10;&#10;You'll receive updates about our latest products and offers.&#10;&#10;Best regards,&#10;{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>
);
}

View File

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

View File

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