feat: Add template editor and push notifications infrastructure

##  Template Editor + Push Notifications

### Backend (PHP)

**1. TemplateProvider** (`includes/Core/Notifications/TemplateProvider.php`)
- Manages notification templates in wp_options
- Default templates for all events x channels
- Variable system (order, product, customer, store)
- Template CRUD operations
- Variable replacement engine

**2. PushNotificationHandler** (`includes/Core/Notifications/PushNotificationHandler.php`)
- VAPID keys generation and storage
- Push subscription management
- Queue system for push notifications
- User-specific subscriptions
- Service worker integration ready

**3. NotificationsController** - Extended with:
- GET /notifications/templates - List all templates
- GET /notifications/templates/:eventId/:channelId - Get template
- POST /notifications/templates - Save template
- DELETE /notifications/templates/:eventId/:channelId - Reset to default
- GET /notifications/push/vapid-key - Get VAPID public key
- POST /notifications/push/subscribe - Subscribe to push
- POST /notifications/push/unsubscribe - Unsubscribe

**4. Push channel added to built-in channels**

### Frontend (React)

**1. TemplateEditor Component** (`TemplateEditor.tsx`)
- Modal dialog for editing templates
- Subject + Body text editors
- Variable insertion with dropdown
- Click-to-insert variables
- Live preview
- Save and reset to default
- Per event + channel customization

**2. Templates Page** - Completely rewritten:
- Lists all events x channels
- Shows "Custom" badge for customized templates
- Edit button opens template editor
- Fetches templates from API
- Variable reference guide
- Organized by channel

### Key Features

 **Simple Text Editor** (not HTML builder)
- Subject line
- Body text with variables
- Variable picker
- Preview mode

 **Variable System**
- Order variables: {order_number}, {order_total}, etc.
- Customer variables: {customer_name}, {customer_email}, etc.
- Product variables: {product_name}, {stock_quantity}, etc.
- Store variables: {store_name}, {store_url}, etc.
- Click to insert at cursor position

 **Push Notifications Ready**
- VAPID key generation
- Subscription management
- Queue system
- PWA integration ready
- Built-in channel (alongside email)

 **Template Management**
- Default templates for all events
- Per-event, per-channel customization
- Reset to default functionality
- Custom badge indicator

### Default Templates Included

**Email:**
- Order Placed, Processing, Completed, Cancelled, Refunded
- Low Stock, Out of Stock
- New Customer, Customer Note

**Push:**
- Order Placed, Processing, Completed
- Low Stock Alert

### Next Steps

1.  Service worker for push notifications
2.  Push subscription UI in Channels page
3.  Test push notifications
4.  Addon integration examples

---

**Ready for testing!** 🚀
This commit is contained in:
dwindown
2025-11-11 13:09:33 +07:00
parent ffdc7aae5f
commit 97e76a837b
6 changed files with 1020 additions and 108 deletions

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { X, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
interface TemplateEditorProps {
open: boolean;
onClose: () => void;
eventId: string;
channelId: string;
eventLabel: string;
channelLabel: string;
template?: any;
}
export default function TemplateEditor({
open,
onClose,
eventId,
channelId,
eventLabel,
channelLabel,
template: initialTemplate,
}: TemplateEditorProps) {
const queryClient = useQueryClient();
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [variables, setVariables] = useState<{ [key: string]: string }>({});
useEffect(() => {
if (initialTemplate) {
setSubject(initialTemplate.subject || '');
setBody(initialTemplate.body || '');
setVariables(initialTemplate.variables || {});
}
}, [initialTemplate]);
const saveMutation = useMutation({
mutationFn: async () => {
return api.post('/notifications/templates', {
eventId,
channelId,
subject,
body,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
toast.success(__('Template saved successfully'));
onClose();
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to save template'));
},
});
const resetMutation = useMutation({
mutationFn: async () => {
return api.del(`/notifications/templates/${eventId}/${channelId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
toast.success(__('Template reset to default'));
onClose();
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to reset template'));
},
});
const insertVariable = (variable: string) => {
const textarea = document.querySelector('textarea[name="body"]') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = body;
const before = text.substring(0, start);
const after = text.substring(end);
const newText = before + `{${variable}}` + after;
setBody(newText);
// Set cursor position after inserted variable
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + variable.length + 2, start + variable.length + 2);
}, 0);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{__('Edit Template')}: {eventLabel} - {channelLabel}
</DialogTitle>
<DialogDescription>
{__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
</div>
{/* Body */}
<div className="space-y-2">
<Label htmlFor="body">{__('Message Body')}</Label>
<Textarea
id="body"
name="body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={__('Enter notification message')}
rows={10}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{__('Use variables from the list below to personalize your message')}
</p>
</div>
{/* Variables */}
<div className="space-y-3">
<Label>{__('Available Variables')}</Label>
<div className="flex flex-wrap gap-2">
{Object.entries(variables).map(([key, label]) => (
<Badge
key={key}
variant="secondary"
className="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => insertVariable(key)}
>
<Plus className="h-3 w-3 mr-1" />
{`{${key}}`}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
{__('Click a variable to insert it at cursor position')}
</p>
</div>
{/* Preview */}
<div className="space-y-2">
<Label>{__('Preview')}</Label>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="font-medium text-sm">{subject || __('(No subject)')}</div>
<div className="text-sm whitespace-pre-wrap">{body || __('(No message)')}</div>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => resetMutation.mutate()} disabled={saveMutation.isPending || resetMutation.isPending}>
{__('Reset to Default')}
</Button>
<Button variant="outline" onClick={onClose}>
{__('Cancel')}
</Button>
<Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending || resetMutation.isPending}>
{saveMutation.isPending ? __('Saving...') : __('Save Template')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,11 +1,12 @@
import React from 'react';
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { SettingsCard } from '../components/SettingsCard';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Mail, MessageCircle, Send, Bell, ExternalLink, Edit, Eye } from 'lucide-react';
import { RefreshCw, Mail, MessageCircle, Send, Bell, Edit } from 'lucide-react';
import { __ } from '@/lib/i18n';
import TemplateEditor from './TemplateEditor';
interface NotificationChannel {
id: string;
@@ -17,12 +18,45 @@ interface NotificationChannel {
}
export default function NotificationTemplates() {
const [editorOpen, setEditorOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<any>(null);
const [selectedChannel, setSelectedChannel] = useState<any>(null);
const [selectedTemplate, setSelectedTemplate] = useState<any>(null);
// Fetch channels
const { data: channels, isLoading } = useQuery({
const { data: channels, isLoading: channelsLoading } = useQuery({
queryKey: ['notification-channels'],
queryFn: () => api.get('/notifications/channels'),
});
// Fetch events
const { data: eventsData, isLoading: eventsLoading } = useQuery({
queryKey: ['notification-events'],
queryFn: () => api.get('/notifications/events'),
});
// Fetch templates
const { data: templates, isLoading: templatesLoading } = useQuery({
queryKey: ['notification-templates'],
queryFn: () => api.get('/notifications/templates'),
});
const openEditor = async (event: any, channel: any) => {
setSelectedEvent(event);
setSelectedChannel(channel);
// Fetch template
try {
const template = await api.get(`/notifications/templates/${event.id}/${channel.id}`);
setSelectedTemplate(template);
} catch (error) {
console.error('Failed to fetch template:', error);
setSelectedTemplate(null);
}
setEditorOpen(true);
};
const getChannelIcon = (channelId: string) => {
switch (channelId) {
case 'email':
@@ -36,7 +70,7 @@ export default function NotificationTemplates() {
}
};
if (isLoading) {
if (channelsLoading || eventsLoading || templatesLoading) {
return (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -44,7 +78,11 @@ export default function NotificationTemplates() {
);
}
const enabledChannels = channels?.filter((c: NotificationChannel) => c.enabled) || [];
const allEvents = [
...(eventsData?.orders || []),
...(eventsData?.products || []),
...(eventsData?.customers || []),
];
return (
<div className="space-y-6">
@@ -67,118 +105,53 @@ export default function NotificationTemplates() {
</div>
</SettingsCard>
{/* Email Templates */}
<SettingsCard
title={__('Email Templates')}
description={__('Customize email notification templates')}
>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg border bg-card">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-primary/10">
<Mail className="h-5 w-5" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{__('Email Templates')}</h3>
<Badge variant="secondary" className="text-xs">
{__('Built-in')}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{__('Email templates are managed by WooCommerce. Customize subject lines, headers, and content.')}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
`${(window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin'}/admin.php?page=wc-settings&tab=email`,
'_blank'
)
}
>
<Edit className="h-4 w-4 mr-2" />
{__('Edit Templates')}
</Button>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<h4 className="font-medium text-sm mb-2">{__('Available Email Templates')}</h4>
<ul className="text-sm text-muted-foreground space-y-1">
<li> {__('New Order (Admin)')}</li>
<li> {__('Order Processing (Customer)')}</li>
<li> {__('Order Completed (Customer)')}</li>
<li> {__('Order Cancelled')}</li>
<li> {__('Order Refunded')}</li>
<li> {__('Customer Note')}</li>
<li> {__('Low Stock Alert')}</li>
<li> {__('Out of Stock Alert')}</li>
</ul>
</div>
</div>
</SettingsCard>
{/* Addon Channel Templates */}
{enabledChannels.filter((c: NotificationChannel) => !c.builtin).length > 0 ? (
{/* Templates by Channel */}
{channels?.map((channel: NotificationChannel) => (
<SettingsCard
title={__('Addon Channel Templates')}
description={__('Templates for addon notification channels')}
key={channel.id}
title={`${channel.label} ${__('Templates')}`}
description={`${__('Customize')} ${channel.label} ${__('notification templates')}`}
>
<div className="space-y-4">
{enabledChannels
.filter((c: NotificationChannel) => !c.builtin)
.map((channel: NotificationChannel) => (
<div key={channel.id} className="flex items-center justify-between p-4 rounded-lg border bg-card">
<div className="flex items-center gap-4">
<div className="p-3 rounded-lg bg-primary/10">{getChannelIcon(channel.id)}</div>
<div>
<div className="space-y-3">
{allEvents.map((event: any) => {
const templateKey = `${event.id}_${channel.id}`;
const hasCustomTemplate = templates && templates[templateKey];
return (
<div
key={`${event.id}_${channel.id}`}
className="flex items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-4 flex-1">
<div className="p-2 rounded-lg bg-primary/10">{getChannelIcon(channel.id)}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{channel.label} {__('Templates')}</h3>
<Badge variant="outline" className="text-xs">
{__('Addon')}
</Badge>
<h4 className="font-medium text-sm">{event.label}</h4>
{hasCustomTemplate && (
<Badge variant="default" className="text-xs">
{__('Custom')}
</Badge>
)}
{channel.builtin && (
<Badge variant="secondary" className="text-xs">
{__('Built-in')}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{__('Customize message templates for')} {channel.label} {__('notifications')}
</p>
<p className="text-xs text-muted-foreground">{event.description}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-2" />
{__('Preview')}
</Button>
<Button variant="outline" size="sm">
<Edit className="h-4 w-4 mr-2" />
{__('Edit')}
</Button>
</div>
<Button variant="outline" size="sm" onClick={() => openEditor(event, channel)}>
<Edit className="h-4 w-4 mr-2" />
{__('Edit')}
</Button>
</div>
))}
);
})}
</div>
</SettingsCard>
) : (
<SettingsCard
title={__('No Addon Templates')}
description={__('Install notification addons to customize their templates')}
>
<div className="text-center py-8">
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-sm text-muted-foreground mb-4">
{__(
'Install notification addons like WhatsApp, Telegram, or SMS to customize their message templates.'
)}
</p>
<Button variant="outline" size="sm">
<ExternalLink className="h-4 w-4 mr-2" />
{__('Browse Addons')}
</Button>
</div>
</SettingsCard>
)}
))}
{/* Template Variables Reference */}
<SettingsCard
@@ -220,6 +193,23 @@ export default function NotificationTemplates() {
</div>
</div>
</SettingsCard>
{/* Template Editor Dialog */}
{selectedEvent && selectedChannel && (
<TemplateEditor
open={editorOpen}
onClose={() => {
setEditorOpen(false);
setSelectedEvent(null);
setSelectedChannel(null);
setSelectedTemplate(null);
}}
eventId={selectedEvent.id}
channelId={selectedChannel.id}
eventLabel={selectedEvent.label}
channelLabel={selectedChannel.label}
template={selectedTemplate}
/>
)}
</div>
);
}