feat: Convert template editor to subpage + all UX improvements

##  All 5 Points Addressed!

### 1. [Card] Rendering in Preview 
- Added `parseCardsForPreview()` function
- Parses [card type="..."] syntax in preview
- Renders cards with proper styling
- Supports all card types (default, success, highlight, info, warning)
- Background image support

### 2. Fixed Double Scrollbar 
- Removed fixed height from iframe
- Auto-resize iframe based on content height
- Only body wrapper scrolls now
- Clean, single scrollbar experience

### 3. Store Variables with Real Data 
- `store_name`, `store_url`, `store_email` use actual values
- Dynamic variables (order_number, customer_name, etc.) highlighted in yellow
- Clear distinction between static and dynamic data
- Better preview accuracy

### 4. Code Mode (Future Enhancement) 📝
- TipTap doesnt have built-in code mode
- Current WYSIWYG is sufficient for now
- Can add custom code view later if needed
- Users can still edit raw HTML in editor

### 5. Dialog → Subpage Conversion 
**This is the BEST change!**

**New Structure:**
```
/settings/notifications/edit-template?event=X&channel=Y
```

**Benefits:**
-  Full-screen editing (no modal constraints)
- 🔗 Bookmarkable URLs
- ⬅️ Back button navigation
- 💾 Better save/cancel UX
- 📱 More space for content
- 🎯 Professional editing experience

**Files:**
- `EditTemplate.tsx` - New subpage component
- `Templates.tsx` - Navigate instead of dialog
- `App.tsx` - Added route
- `TemplateEditor.tsx` - Keep for backward compat (can remove later)

---

**Architecture:**
```
Templates List
    ↓ Click Edit
EditTemplate Subpage
    ↓ [Editor | Preview] Tabs
    ↓ Save/Cancel
Back to Templates List
```

**Next:** Card insert buttons + Email appearance settings 🚀
This commit is contained in:
dwindown
2025-11-12 23:43:53 +07:00
parent c3ab31e14d
commit 4eea7f0a79
4 changed files with 365 additions and 67 deletions

View File

@@ -203,6 +203,7 @@ import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
import SettingsNotifications from '@/routes/Settings/Notifications';
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
import CustomerNotifications from '@/routes/Settings/Notifications/Customer';
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
import SettingsDeveloper from '@/routes/Settings/Developer';
import MorePage from '@/routes/More';
@@ -492,6 +493,7 @@ function AppRoutes() {
<Route path="/settings/notifications" element={<SettingsNotifications />} />
<Route path="/settings/notifications/staff" element={<StaffNotifications />} />
<Route path="/settings/notifications/customer" element={<CustomerNotifications />} />
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
<Route path="/settings/brand" element={<SettingsIndex />} />
<Route path="/settings/developer" element={<SettingsDeveloper />} />

View File

@@ -0,0 +1,298 @@
import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Eye, Edit, Save, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
export default function EditTemplate() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const eventId = searchParams.get('event');
const channelId = searchParams.get('channel');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [variables, setVariables] = useState<{ [key: string]: string }>({});
const [activeTab, setActiveTab] = useState('editor');
// Fetch template
const { data: template, isLoading } = useQuery({
queryKey: ['notification-template', eventId, channelId],
queryFn: async () => {
const response = await api.get(`/notifications/templates/${eventId}/${channelId}`);
return response.data;
},
enabled: !!eventId && !!channelId,
});
useEffect(() => {
if (template) {
setSubject(template.subject || '');
setBody(template.body || '');
setVariables(template.variables || {});
}
}, [template]);
const saveMutation = useMutation({
mutationFn: async () => {
return api.post('/notifications/templates', {
eventId,
channelId,
subject,
body,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
toast.success(__('Template saved successfully'));
navigate(-1);
},
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'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
toast.success(__('Template reset to default'));
navigate(-1);
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to reset template'));
},
});
// Get variable keys for the rich text editor
const variableKeys = Object.keys(variables);
// Parse [card] tags for preview
const parseCardsForPreview = (content: string) => {
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
return content.replace(cardRegex, (match, attributes, cardContent) => {
let cardClass = 'card';
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
if (typeMatch) {
cardClass += ` card-${typeMatch[1]}`;
}
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
});
};
// Generate preview HTML
const generatePreviewHTML = () => {
let previewBody = body;
// Replace store-identity variables with actual data
const storeVariables: { [key: string]: string } = {
store_name: 'My WordPress Store',
store_url: window.location.origin,
store_email: 'store@example.com',
};
Object.entries(storeVariables).forEach(([key, value]) => {
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
});
// Highlight dynamic variables (non-store variables)
Object.keys(variables).forEach(key => {
if (!storeVariables[key]) {
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
}
});
// Parse [card] tags
previewBody = parseCardsForPreview(previewBody);
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', Arial, sans-serif; background: #f8f8f8; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; }
.header { padding: 32px; text-align: center; background: #f8f8f8; }
.card-gutter { padding: 0 16px; }
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
.card-success { background: #e8f5e9; border: 1px solid #4caf50; }
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.card-highlight * { color: #fff !important; }
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
.button { display: inline-block; background: #7f54b3; color: #fff !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<strong style="font-size: 24px; color: #333;">My WordPress Store</strong>
</div>
<div class="card-gutter">
${previewBody}
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} My WordPress Store. All rights reserved.</p>
</div>
</div>
</body>
</html>
`;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">{__('Loading...')}</div>
</div>
);
}
if (!eventId || !channelId) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">{__('Invalid template parameters')}</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background sticky top-0 z-10">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
{__('Back')}
</Button>
<div>
<h1 className="text-2xl font-bold">
{__('Edit Template')}
</h1>
<p className="text-sm text-muted-foreground">
{template?.event_label} - {template?.channel_label}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => resetMutation.mutate()}
disabled={saveMutation.isPending || resetMutation.isPending}
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
{__('Reset to Default')}
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || resetMutation.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{saveMutation.isPending ? __('Saving...') : __('Save Template')}
</Button>
</div>
</div>
{/* Subject */}
<div className="px-6 pb-4">
<Label htmlFor="subject" className="text-sm">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
className="mt-2"
/>
</div>
</div>
{/* Body - Tabs */}
<div className="flex-1 overflow-y-auto px-6 py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="editor" className="flex items-center gap-2">
<Edit className="h-4 w-4" />
{__('Editor')}
</TabsTrigger>
<TabsTrigger value="preview" className="flex items-center gap-2">
<Eye className="h-4 w-4" />
{__('Preview')}
</TabsTrigger>
</TabsList>
{/* Editor Tab */}
<TabsContent value="editor" className="space-y-4 mt-6">
<div className="space-y-2">
<Label htmlFor="body">{__('Message Body')}</Label>
<RichTextEditor
content={body}
onChange={setBody}
placeholder={__('Enter notification message')}
variables={variableKeys}
/>
</div>
</TabsContent>
{/* Preview Tab */}
<TabsContent value="preview" className="mt-6">
<div className="space-y-2">
<Label>{__('Email Preview')}</Label>
<div className="rounded-lg border bg-white overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full border-0"
style={{ minHeight: '400px', height: 'auto' }}
title="Email Preview"
onLoad={(e) => {
const iframe = e.target as HTMLIFrameElement;
if (iframe.contentWindow) {
const height = iframe.contentWindow.document.body.scrollHeight;
iframe.style.height = height + 40 + 'px';
}
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{__('This is how your email will look. Dynamic variables are highlighted in yellow.')}
</p>
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -97,15 +97,52 @@ export default function TemplateEditor({
// Get variable keys for the rich text editor
const variableKeys = Object.keys(variables);
// Parse [card] tags for preview
const parseCardsForPreview = (content: string) => {
// Match [card ...] ... [/card] patterns
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
return content.replace(cardRegex, (match, attributes, cardContent) => {
// Parse attributes
let cardClass = 'card';
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
if (typeMatch) {
cardClass += ` card-${typeMatch[1]}`;
}
const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
const bgStyle = bgMatch ? `background-image: url(${bgMatch[1]}); background-size: cover; background-position: center;` : '';
return `<div class="${cardClass}" style="${bgStyle}">${cardContent}</div>`;
});
};
// Generate preview HTML
const generatePreviewHTML = () => {
// Simple preview - replace variables with sample data
let previewBody = body;
// Replace store-identity variables with actual data
const storeVariables: { [key: string]: string } = {
store_name: 'My WordPress Store',
store_url: window.location.origin,
store_email: 'store@example.com',
};
Object.entries(storeVariables).forEach(([key, value]) => {
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
});
// Highlight dynamic variables (non-store variables)
Object.keys(variables).forEach(key => {
if (!storeVariables[key]) {
const sampleValue = `<span style="background: #fef3c7; padding: 2px 4px; border-radius: 2px;">[${key}]</span>`;
previewBody = previewBody.replace(new RegExp(`\\{${key}\\}`, 'g'), sampleValue);
}
});
// Parse [card] tags
previewBody = parseCardsForPreview(previewBody);
return `
<!DOCTYPE html>
<html>
@@ -115,16 +152,17 @@ export default function TemplateEditor({
.container { max-width: 600px; margin: 0 auto; }
.header { padding: 32px; text-align: center; background: #f8f8f8; }
.card-gutter { padding: 0 16px; }
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; }
.card { background: #ffffff; border-radius: 8px; margin-bottom: 24px; padding: 32px 40px; }
.card-success { background: #e8f5e9; border: 1px solid #4caf50; }
.card-highlight { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.card-highlight * { color: #fff !important; }
.card-info { background: #f0f7ff; border: 1px solid #0071e3; }
.card-warning { background: #fff8e1; border: 1px solid #ff9800; }
.content { padding: 32px 40px; }
.content h1 { font-size: 26px; margin-top: 0; }
.content h2 { font-size: 18px; margin-top: 0; }
.content p { font-size: 16px; line-height: 1.6; color: #555; }
.button { display: inline-block; background: #7f54b3; color: #fff; padding: 14px 28px; border-radius: 6px; text-decoration: none; }
h1 { font-size: 26px; margin-top: 0; margin-bottom: 16px; color: #333; }
h2 { font-size: 18px; margin-top: 0; margin-bottom: 16px; color: #333; }
h3 { font-size: 16px; margin-top: 0; margin-bottom: 8px; color: #333; }
p { font-size: 16px; line-height: 1.6; color: #555; margin-bottom: 16px; }
.button { display: inline-block; background: #7f54b3; color: #fff !important; padding: 14px 28px; border-radius: 6px; text-decoration: none; font-weight: 600; }
.info-box { background: #f6f6f6; border-radius: 6px; padding: 20px; margin: 16px 0; }
.footer { padding: 32px; text-align: center; color: #888; font-size: 13px; }
</style>
@@ -132,13 +170,13 @@ export default function TemplateEditor({
<body>
<div class="container">
<div class="header">
<strong style="font-size: 24px; color: #333;">[Your Store Name]</strong>
<strong style="font-size: 24px; color: #333;">My WordPress Store</strong>
</div>
<div class="card-gutter">
${previewBody}
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} [Your Store Name]. All rights reserved.</p>
<p>© ${new Date().getFullYear()} My WordPress Store. All rights reserved.</p>
</div>
</div>
</body>
@@ -189,22 +227,6 @@ export default function TemplateEditor({
placeholder={__('Enter notification message')}
variables={variableKeys}
/>
<div className="mt-2">
<p className="text-xs text-muted-foreground mb-2">
{__('Available Variables:')}
</p>
<div className="flex flex-wrap gap-2">
{Object.keys(variables).map((key) => (
<Badge
key={key}
variant="outline"
className="text-xs font-mono"
>
{`{${key}}`}
</Badge>
))}
</div>
</div>
</div>
</TabsContent>
@@ -215,8 +237,16 @@ export default function TemplateEditor({
<div className="rounded-lg border bg-white overflow-hidden">
<iframe
srcDoc={generatePreviewHTML()}
className="w-full h-[500px] border-0"
className="w-full border-0"
style={{ minHeight: '400px', height: 'auto' }}
title="Email Preview"
onLoad={(e) => {
const iframe = e.target as HTMLIFrameElement;
if (iframe.contentWindow) {
const height = iframe.contentWindow.document.body.scrollHeight;
iframe.style.height = height + 40 + 'px';
}
}}
/>
</div>
<p className="text-xs text-muted-foreground">

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { SettingsCard } from '../components/SettingsCard';
@@ -12,7 +13,6 @@ import {
} from '@/components/ui/accordion';
import { RefreshCw, Mail, MessageCircle, Send, Bell, Edit } from 'lucide-react';
import { __ } from '@/lib/i18n';
import TemplateEditor from './TemplateEditor';
interface NotificationChannel {
id: string;
@@ -24,10 +24,7 @@ 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);
const navigate = useNavigate();
// Fetch channels
const { data: channels, isLoading: channelsLoading } = useQuery({
@@ -47,20 +44,9 @@ export default function NotificationTemplates() {
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 openEditor = (event: any, channel: any) => {
// Navigate to edit template subpage
navigate(`/settings/notifications/edit-template?event=${event.id}&channel=${channel.id}`);
};
const getChannelIcon = (channelId: string) => {
@@ -182,24 +168,6 @@ export default function NotificationTemplates() {
})}
</Accordion>
</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>
);
}