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:
203
admin-spa/src/routes/Settings/Notifications/TemplateEditor.tsx
Normal file
203
admin-spa/src/routes/Settings/Notifications/TemplateEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user