1618 lines
43 KiB
Markdown
1618 lines
43 KiB
Markdown
# Email Template Editor
|
|
|
|
## Overview
|
|
|
|
The Email Template Editor provides a powerful interface for creating and modifying email templates with live preview, variable insertion, and test send functionality. It supports split-pane editing for HTML and plain text versions, visual variable insertion, and real-time rendering with sample data.
|
|
|
|
**Key Features:**
|
|
|
|
- **Split-Pane Editor** — Side-by-side HTML and text editing
|
|
- **Variable Insertion Buttons** — Click to insert {{VARIABLES}} at cursor position
|
|
- **Live Preview Rendering** — See rendered HTML with sample data in real-time
|
|
- **Test Send Functionality** — Send test emails with custom sample data
|
|
- **Auto-Save Drafts** — Prevent data loss with automatic draft saving
|
|
- **Version Creation** — Every save creates a new version with change notes
|
|
- **Responsive Layout** — Desktop-optimized (mobile warning for small screens)
|
|
- **Keyboard Shortcuts** — Ctrl+S to save, Ctrl+P to preview, Esc to close
|
|
|
|
**Access Control:**
|
|
- **Role Required:** SUPER_ADMIN only
|
|
- **Route:** `/app/email-templates/:id/edit`
|
|
- **Layout:** Full-screen (no AppLayout sidebar)
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph "Editor UI Components"
|
|
Editor[EmailTemplateEditorPage]
|
|
Toolbar[Editor Toolbar]
|
|
HtmlEditor[HTML Editor Pane]
|
|
TextEditor[Text Editor Pane]
|
|
VarPanel[Variable Insertion Panel]
|
|
Preview[Live Preview Pane]
|
|
TestForm[Test Send Form]
|
|
end
|
|
|
|
subgraph "State Management"
|
|
State[Component State]
|
|
Draft[LocalStorage Draft]
|
|
AutoSave[Auto-Save Timer]
|
|
end
|
|
|
|
subgraph "API Layer"
|
|
GetTemplate[GET /api/email-templates/:id]
|
|
UpdateTemplate[PUT /api/email-templates/:id]
|
|
TestSend[POST /api/email-templates/:id/test]
|
|
end
|
|
|
|
subgraph "Backend Processing"
|
|
Template[(EmailTemplate)]
|
|
Variables[(EmailTemplateVariable)]
|
|
Handlebars[Handlebars Compiler]
|
|
EmailService[Email Service]
|
|
TestLog[(EmailTemplateTestLog)]
|
|
end
|
|
|
|
Editor --> Toolbar
|
|
Editor --> HtmlEditor
|
|
Editor --> TextEditor
|
|
Editor --> VarPanel
|
|
Editor --> Preview
|
|
Editor --> TestForm
|
|
|
|
Editor --> State
|
|
State --> Draft
|
|
State --> AutoSave
|
|
|
|
Editor -->|Load| GetTemplate
|
|
GetTemplate --> Template
|
|
GetTemplate --> Variables
|
|
|
|
VarPanel -->|Insert| HtmlEditor
|
|
VarPanel -->|Insert| TextEditor
|
|
|
|
HtmlEditor -->|Debounce 300ms| Preview
|
|
TextEditor --> State
|
|
|
|
Preview --> Handlebars
|
|
Handlebars -->|Render HTML| Preview
|
|
|
|
Toolbar -->|Save Click| UpdateTemplate
|
|
UpdateTemplate --> Template
|
|
UpdateTemplate -->|Create Version| Versions[(EmailTemplateVersion)]
|
|
|
|
TestForm --> TestSend
|
|
TestSend --> EmailService
|
|
EmailService -->|Send| SMTP[Nodemailer]
|
|
SMTP --> TestLog
|
|
|
|
AutoSave --> Draft
|
|
|
|
style Editor fill:#4a90e2,color:#fff
|
|
style Template fill:#50c878,color:#fff
|
|
style Preview fill:#ffb347,color:#333
|
|
```
|
|
|
|
**Data Flow:**
|
|
|
|
1. **Load Template** — Fetch template + variables via GET API
|
|
2. **Restore Draft** — Load from localStorage if exists (unsaved changes)
|
|
3. **Edit Content** — Type in HTML/text editors, updates component state
|
|
4. **Insert Variable** — Click variable button → inserts `{{VAR}}` at cursor
|
|
5. **Preview Update** — Debounced (300ms) Handlebars compilation + iframe render
|
|
6. **Test Send** — Enter recipient + sample data → POST to test endpoint → email sent
|
|
7. **Save Template** — Click save → PUT API → create version → clear draft → redirect
|
|
8. **Auto-Save Draft** — Blur event → save to localStorage (not database)
|
|
|
|
---
|
|
|
|
## Editor Components
|
|
|
|
### Toolbar
|
|
|
|
**Location:** Top bar (sticky)
|
|
|
|
**Elements:**
|
|
- **Template Name** — Read-only display (left)
|
|
- **Save Button** — Saves changes and creates version (right)
|
|
- **Preview Toggle** — Show/hide live preview pane (right)
|
|
- **Test Send Button** — Opens test send modal (right)
|
|
- **Back Button** — Returns to EmailTemplatesPage (left)
|
|
|
|
**Actions:**
|
|
```typescript
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await api.put(`/api/email-templates/${id}`, {
|
|
subjectLine,
|
|
htmlContent,
|
|
textContent,
|
|
changeNotes,
|
|
});
|
|
|
|
message.success('Template saved successfully');
|
|
localStorage.removeItem(`email-template-draft-${id}`);
|
|
navigate('/app/email-templates');
|
|
} catch (error) {
|
|
message.error('Failed to save template');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### HTML Editor Pane
|
|
|
|
**Location:** Left side (50% width) or full width when preview hidden
|
|
|
|
**Features:**
|
|
- **Textarea or Monaco Editor** — Syntax highlighting (Monaco upgrade path)
|
|
- **Line Numbers** — Visual line number gutter
|
|
- **Auto-Resize** — Grows to fit content (max 80vh)
|
|
- **Tab Support** — Tab key inserts 2 spaces (not focus change)
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
const [htmlContent, setHtmlContent] = useState('');
|
|
const htmlEditorRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setHtmlContent(e.target.value);
|
|
debouncedPreview(e.target.value, sampleData);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// Tab key support
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const textarea = e.currentTarget;
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
|
|
setHtmlContent(
|
|
htmlContent.substring(0, start) + ' ' + htmlContent.substring(end)
|
|
);
|
|
|
|
setTimeout(() => {
|
|
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
|
}, 0);
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Text Editor Pane
|
|
|
|
**Location:** Left side (50% width) or full width when preview hidden
|
|
|
|
**Features:**
|
|
- **Plain Text Editing** — No syntax highlighting needed
|
|
- **Auto-Resize** — Matches HTML editor height
|
|
- **Variable Insertion** — Same insertion panel as HTML editor
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
const [textContent, setTextContent] = useState('');
|
|
const textEditorRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
setTextContent(e.target.value);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Variable Insertion Panel
|
|
|
|
**Location:** Right sidebar (collapsible)
|
|
|
|
**Features:**
|
|
- **Variable List** — All template variables with labels
|
|
- **Insert Buttons** — Click to insert `{{VAR}}` at cursor
|
|
- **Required Badge** — Red badge for required variables
|
|
- **Conditional Badge** — Blue badge for conditional variables
|
|
- **Sample Value Display** — Shows example value below each variable
|
|
- **Search/Filter** — Filter variables by name (if many variables)
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
|
|
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const content = editorType === 'html' ? htmlContent : textContent;
|
|
|
|
const before = content.substring(0, start);
|
|
const after = content.substring(end);
|
|
const newContent = before + `{{${variableKey}}}` + after;
|
|
|
|
if (editorType === 'html') {
|
|
setHtmlContent(newContent);
|
|
} else {
|
|
setTextContent(newContent);
|
|
}
|
|
|
|
// Move cursor after inserted variable
|
|
setTimeout(() => {
|
|
const newPos = start + variableKey.length + 4; // 4 = {{ + }}
|
|
textarea.selectionStart = newPos;
|
|
textarea.selectionEnd = newPos;
|
|
textarea.focus();
|
|
}, 0);
|
|
};
|
|
```
|
|
|
|
**Variable List UI:**
|
|
```typescript
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
{variables
|
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
.map((variable) => (
|
|
<Card key={variable.id} size="small">
|
|
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
|
<Space>
|
|
<Text strong>{variable.label}</Text>
|
|
{variable.isRequired && <Tag color="red">Required</Tag>}
|
|
{variable.isConditional && <Tag color="blue">Conditional</Tag>}
|
|
</Space>
|
|
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{variable.description}
|
|
</Text>
|
|
|
|
{variable.sampleValue && (
|
|
<Text code style={{ fontSize: 11 }}>
|
|
Example: {variable.sampleValue}
|
|
</Text>
|
|
)}
|
|
|
|
<Space size="small">
|
|
<Button
|
|
size="small"
|
|
onClick={() => handleInsertVariable(variable.key, 'html')}
|
|
>
|
|
Insert to HTML
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
onClick={() => handleInsertVariable(variable.key, 'text')}
|
|
>
|
|
Insert to Text
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</Card>
|
|
))}
|
|
</Space>
|
|
```
|
|
|
|
---
|
|
|
|
### Live Preview Pane
|
|
|
|
**Location:** Right side (50% width) when enabled
|
|
|
|
**Features:**
|
|
- **Iframe Rendering** — Isolated HTML preview
|
|
- **Sample Data Form** — Edit sample variable values
|
|
- **Desktop/Mobile Toggle** — Preview in different viewport sizes
|
|
- **Debounced Updates** — Renders 300ms after typing stops
|
|
- **Error Display** — Shows Handlebars compilation errors
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
import Handlebars from 'handlebars';
|
|
|
|
const [previewHtml, setPreviewHtml] = useState('');
|
|
const [sampleData, setSampleData] = useState<Record<string, unknown>>({});
|
|
const previewRef = useRef<HTMLIFrameElement>(null);
|
|
|
|
const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {
|
|
try {
|
|
const compiled = Handlebars.compile(html);
|
|
const rendered = compiled(data);
|
|
|
|
// Inject into iframe
|
|
if (previewRef.current?.contentDocument) {
|
|
const doc = previewRef.current.contentDocument;
|
|
doc.open();
|
|
doc.write(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
body { font-family: Arial, sans-serif; padding: 20px; }
|
|
</style>
|
|
</head>
|
|
<body>${rendered}</body>
|
|
</html>
|
|
`);
|
|
doc.close();
|
|
}
|
|
|
|
setPreviewHtml(rendered);
|
|
} catch (error) {
|
|
console.error('Preview render error:', error);
|
|
setPreviewError(error.message);
|
|
}
|
|
}, []);
|
|
|
|
const debouncedPreview = useMemo(
|
|
() => debounce(renderPreview, 300),
|
|
[renderPreview]
|
|
);
|
|
|
|
// Update preview when HTML or sample data changes
|
|
useEffect(() => {
|
|
debouncedPreview(htmlContent, sampleData);
|
|
}, [htmlContent, sampleData, debouncedPreview]);
|
|
```
|
|
|
|
**Sample Data Form:**
|
|
```typescript
|
|
const handleSampleDataChange = (variableKey: string, value: unknown) => {
|
|
setSampleData((prev) => ({
|
|
...prev,
|
|
[variableKey]: value,
|
|
}));
|
|
};
|
|
|
|
// Render form
|
|
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
|
<Title level={5}>Sample Data</Title>
|
|
{variables.map((variable) => (
|
|
<Form.Item key={variable.id} label={variable.label}>
|
|
<Input
|
|
value={sampleData[variable.key] as string || ''}
|
|
onChange={(e) => handleSampleDataChange(variable.key, e.target.value)}
|
|
placeholder={variable.sampleValue || ''}
|
|
/>
|
|
</Form.Item>
|
|
))}
|
|
</Space>
|
|
```
|
|
|
|
---
|
|
|
|
### Test Send Form
|
|
|
|
**Location:** Modal dialog
|
|
|
|
**Features:**
|
|
- **Recipient Email Input** — Where to send test email
|
|
- **Sample Data Editor** — JSON editor or form fields
|
|
- **Send Button** — Triggers test send API call
|
|
- **Success/Failure Notification** — Shows send result
|
|
- **Test Log Link** — Link to test send history
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
const [testModalVisible, setTestModalVisible] = useState(false);
|
|
const [testRecipient, setTestRecipient] = useState('');
|
|
const [testData, setTestData] = useState<Record<string, unknown>>({});
|
|
|
|
const handleTestSend = async () => {
|
|
if (!testRecipient) {
|
|
message.error('Please enter recipient email');
|
|
return;
|
|
}
|
|
|
|
setTestSending(true);
|
|
try {
|
|
await api.post(`/api/email-templates/${id}/test`, {
|
|
recipientEmail: testRecipient,
|
|
testData,
|
|
});
|
|
|
|
message.success('Test email sent successfully');
|
|
setTestModalVisible(false);
|
|
} catch (error) {
|
|
message.error('Failed to send test email');
|
|
} finally {
|
|
setTestSending(false);
|
|
}
|
|
};
|
|
|
|
// Modal UI
|
|
<Modal
|
|
title="Send Test Email"
|
|
visible={testModalVisible}
|
|
onOk={handleTestSend}
|
|
onCancel={() => setTestModalVisible(false)}
|
|
confirmLoading={testSending}
|
|
okText="Send Test"
|
|
>
|
|
<Form layout="vertical">
|
|
<Form.Item label="Recipient Email" required>
|
|
<Input
|
|
type="email"
|
|
value={testRecipient}
|
|
onChange={(e) => setTestRecipient(e.target.value)}
|
|
placeholder="your-email@example.com"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="Sample Data">
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
{variables.map((variable) => (
|
|
<Input
|
|
key={variable.id}
|
|
addonBefore={variable.label}
|
|
value={testData[variable.key] as string || ''}
|
|
onChange={(e) =>
|
|
setTestData((prev) => ({ ...prev, [variable.key]: e.target.value }))
|
|
}
|
|
placeholder={variable.sampleValue || ''}
|
|
/>
|
|
))}
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
```
|
|
|
|
---
|
|
|
|
## Admin Workflow
|
|
|
|
### Opening Editor
|
|
|
|
**From EmailTemplatesPage:**
|
|
|
|
1. Click template row in table
|
|
2. Opens template detail modal
|
|
3. Click "Edit" button in modal
|
|
4. Opens EmailTemplateEditorPage in same tab
|
|
|
|
**Direct URL:**
|
|
```
|
|
/app/email-templates/{id}/edit
|
|
```
|
|
|
|
**Route Definition:**
|
|
```typescript
|
|
// admin/src/App.tsx
|
|
|
|
<Route
|
|
path="/app/email-templates/:id/edit"
|
|
element={
|
|
<ProtectedRoute allowedRoles={[SUPER_ADMIN]}>
|
|
<EmailTemplateEditorPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
### Editing HTML Content
|
|
|
|
**Step 1: Load Template**
|
|
- Template data fetched via API on component mount
|
|
- HTML/text content populated in editors
|
|
- Variables loaded in insertion panel
|
|
|
|
**Step 2: Edit HTML**
|
|
- Type HTML with `{{VARIABLES}}` placeholders
|
|
- Use variable insertion buttons for convenience
|
|
- Preview updates automatically (300ms debounce)
|
|
|
|
**Step 3: Insert Variables**
|
|
- Click variable "Insert to HTML" button
|
|
- `{{VARIABLE_KEY}}` inserted at cursor position
|
|
- Cursor moves after inserted variable
|
|
|
|
**Step 4: Preview Changes**
|
|
- Live preview pane shows rendered HTML
|
|
- Edit sample data to test different values
|
|
- Check for formatting issues
|
|
|
|
**Example Editing Session:**
|
|
```html
|
|
<!-- Initial HTML -->
|
|
<p>Dear {{USER_NAME}},</p>
|
|
<p>Thank you for signing up.</p>
|
|
|
|
<!-- Add shift details -->
|
|
<p>Dear {{USER_NAME}},</p>
|
|
<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>
|
|
<ul>
|
|
<li>Date: {{SHIFT_DATE}}</li>
|
|
<li>Time: {{SHIFT_TIME}}</li>
|
|
</ul>
|
|
|
|
<!-- Add conditional phone -->
|
|
<p>Dear {{USER_NAME}},</p>
|
|
<p>Thank you for signing up for <strong>{{SHIFT_TITLE}}</strong>.</p>
|
|
<ul>
|
|
<li>Date: {{SHIFT_DATE}}</li>
|
|
<li>Time: {{SHIFT_TIME}}</li>
|
|
</ul>
|
|
|
|
{{#if HAS_PHONE}}
|
|
<p>We'll call you at {{USER_PHONE}} if there are any changes.</p>
|
|
{{/if}}
|
|
```
|
|
|
|
---
|
|
|
|
### Using Variable Insertion
|
|
|
|
**Keyboard Method:**
|
|
1. Type `{{` in HTML editor
|
|
2. Type variable name (e.g., `USER_NAME`)
|
|
3. Type `}}`
|
|
|
|
**Button Method:**
|
|
1. Place cursor where you want variable
|
|
2. Click variable "Insert to HTML" button
|
|
3. `{{VARIABLE_KEY}}` inserted at cursor
|
|
4. Cursor moves to end of insertion
|
|
|
|
**Insertion Logic:**
|
|
```typescript
|
|
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
|
|
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const content = editorType === 'html' ? htmlContent : textContent;
|
|
|
|
// Replace selection with variable
|
|
const before = content.substring(0, start);
|
|
const after = content.substring(end);
|
|
const variable = `{{${variableKey}}}`;
|
|
const newContent = before + variable + after;
|
|
|
|
// Update state
|
|
if (editorType === 'html') {
|
|
setHtmlContent(newContent);
|
|
} else {
|
|
setTextContent(newContent);
|
|
}
|
|
|
|
// Move cursor to end of inserted variable
|
|
setTimeout(() => {
|
|
const newPos = start + variable.length;
|
|
textarea.selectionStart = newPos;
|
|
textarea.selectionEnd = newPos;
|
|
textarea.focus();
|
|
}, 0);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Live Preview
|
|
|
|
**Preview Update Flow:**
|
|
|
|
1. **Type in HTML Editor**
|
|
- `onChange` event fires
|
|
- Updates `htmlContent` state
|
|
- Triggers debounced preview render (300ms)
|
|
|
|
2. **Debounced Render**
|
|
- Waits 300ms after typing stops
|
|
- Compiles Handlebars template
|
|
- Interpolates with sample data
|
|
- Injects HTML into iframe
|
|
|
|
3. **Sample Data Changes**
|
|
- Edit sample data form fields
|
|
- Updates `sampleData` state
|
|
- Immediately triggers preview render (no debounce)
|
|
|
|
**Preview Error Handling:**
|
|
```typescript
|
|
const renderPreview = (html: string, data: Record<string, unknown>) => {
|
|
try {
|
|
const compiled = Handlebars.compile(html);
|
|
const rendered = compiled(data);
|
|
|
|
// Inject into iframe...
|
|
setPreviewError(null);
|
|
} catch (error) {
|
|
// Show error in preview pane
|
|
setPreviewError(error.message);
|
|
|
|
if (previewRef.current?.contentDocument) {
|
|
const doc = previewRef.current.contentDocument;
|
|
doc.open();
|
|
doc.write(`
|
|
<div style="color: red; padding: 20px;">
|
|
<h3>Preview Error</h3>
|
|
<pre>${error.message}</pre>
|
|
</div>
|
|
`);
|
|
doc.close();
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Testing Template
|
|
|
|
**Step 1: Click "Send Test" Button**
|
|
- Opens test send modal
|
|
|
|
**Step 2: Enter Recipient Email**
|
|
- Your email address (or test account)
|
|
- Validates email format before sending
|
|
|
|
**Step 3: Edit Sample Data**
|
|
- Pre-filled with variable sample values
|
|
- Modify to test specific scenarios
|
|
- Example: Set `HAS_PHONE` to `false` to test conditional block
|
|
|
|
**Step 4: Click "Send Test"**
|
|
- POST request to `/api/email-templates/:id/test`
|
|
- Email sent via SMTP (or MailHog in test mode)
|
|
- Success notification displayed
|
|
|
|
**Step 5: Check Email**
|
|
- Open email client (or MailHog at http://localhost:8025)
|
|
- Verify rendering, variables, formatting
|
|
- Test links, images, layout
|
|
|
|
**Step 6: Review Test Log**
|
|
- Navigate to "Test Logs" tab in template detail modal
|
|
- See test send history (recipient, timestamp, success/failure)
|
|
- Debug errors if send failed
|
|
|
|
---
|
|
|
|
### Saving Changes
|
|
|
|
**Step 1: Click "Save" Button**
|
|
- Toolbar save button (or Ctrl+S keyboard shortcut)
|
|
|
|
**Step 2: Enter Change Notes**
|
|
- Modal prompts for change description
|
|
- Used for version history audit trail
|
|
- Optional but recommended
|
|
|
|
**Step 3: Confirm Save**
|
|
- PUT request to `/api/email-templates/:id`
|
|
- Creates new version automatically
|
|
- Clears localStorage draft
|
|
- Redirects to EmailTemplatesPage
|
|
|
|
**Save Implementation:**
|
|
```typescript
|
|
const [saveModalVisible, setSaveModalVisible] = useState(false);
|
|
const [changeNotes, setChangeNotes] = useState('');
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await api.put(`/api/email-templates/${id}`, {
|
|
subjectLine,
|
|
htmlContent,
|
|
textContent,
|
|
changeNotes: changeNotes || undefined,
|
|
});
|
|
|
|
message.success('Template saved successfully');
|
|
|
|
// Clear draft
|
|
localStorage.removeItem(`email-template-draft-${id}`);
|
|
|
|
// Redirect
|
|
navigate('/app/email-templates');
|
|
} catch (error) {
|
|
message.error('Failed to save template');
|
|
} finally {
|
|
setSaving(false);
|
|
setSaveModalVisible(false);
|
|
}
|
|
};
|
|
|
|
// Keyboard shortcut
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
setSaveModalVisible(true);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
```
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### EmailTemplateEditorPage Component
|
|
|
|
**Full Component Structure:**
|
|
|
|
```typescript
|
|
// admin/src/pages/EmailTemplateEditorPage.tsx
|
|
|
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { Button, Input, Space, Card, Tag, Typography, Modal, Form, message } from 'antd';
|
|
import { SaveOutlined, SendOutlined, ArrowLeftOutlined, EyeOutlined } from '@ant-design/icons';
|
|
import Handlebars from 'handlebars';
|
|
import { debounce } from 'lodash';
|
|
import { api } from '@/lib/api';
|
|
import type { EmailTemplate, EmailTemplateVariable } from '@/types/api';
|
|
|
|
const { Title, Text } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
export default function EmailTemplateEditorPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
// State
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [template, setTemplate] = useState<EmailTemplate | null>(null);
|
|
const [variables, setVariables] = useState<EmailTemplateVariable[]>([]);
|
|
|
|
const [subjectLine, setSubjectLine] = useState('');
|
|
const [htmlContent, setHtmlContent] = useState('');
|
|
const [textContent, setTextContent] = useState('');
|
|
|
|
const [showPreview, setShowPreview] = useState(true);
|
|
const [sampleData, setSampleData] = useState<Record<string, unknown>>({});
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
|
|
const [testModalVisible, setTestModalVisible] = useState(false);
|
|
const [testRecipient, setTestRecipient] = useState('');
|
|
const [testSending, setTestSending] = useState(false);
|
|
|
|
const [saveModalVisible, setSaveModalVisible] = useState(false);
|
|
const [changeNotes, setChangeNotes] = useState('');
|
|
|
|
// Refs
|
|
const htmlEditorRef = useRef<HTMLTextAreaElement>(null);
|
|
const textEditorRef = useRef<HTMLTextAreaElement>(null);
|
|
const previewRef = useRef<HTMLIFrameElement>(null);
|
|
|
|
// Load template
|
|
useEffect(() => {
|
|
const loadTemplate = async () => {
|
|
try {
|
|
const response = await api.get(`/api/email-templates/${id}`);
|
|
const { template: tmpl, variables: vars } = response.data;
|
|
|
|
setTemplate(tmpl);
|
|
setVariables(vars);
|
|
|
|
setSubjectLine(tmpl.subjectLine);
|
|
setHtmlContent(tmpl.htmlContent);
|
|
setTextContent(tmpl.textContent);
|
|
|
|
// Initialize sample data from variable sample values
|
|
const initialSampleData: Record<string, unknown> = {};
|
|
vars.forEach((v: EmailTemplateVariable) => {
|
|
if (v.sampleValue) {
|
|
initialSampleData[v.key] = v.sampleValue;
|
|
}
|
|
});
|
|
setSampleData(initialSampleData);
|
|
|
|
// Restore draft if exists
|
|
const draft = localStorage.getItem(`email-template-draft-${id}`);
|
|
if (draft) {
|
|
const { subjectLine: draftSubject, htmlContent: draftHtml, textContent: draftText } = JSON.parse(draft);
|
|
setSubjectLine(draftSubject);
|
|
setHtmlContent(draftHtml);
|
|
setTextContent(draftText);
|
|
message.info('Restored unsaved changes from draft');
|
|
}
|
|
|
|
setLoading(false);
|
|
} catch (error) {
|
|
message.error('Failed to load template');
|
|
navigate('/app/email-templates');
|
|
}
|
|
};
|
|
|
|
loadTemplate();
|
|
}, [id, navigate]);
|
|
|
|
// Auto-save draft to localStorage
|
|
useEffect(() => {
|
|
if (!loading && template) {
|
|
const draft = {
|
|
subjectLine,
|
|
htmlContent,
|
|
textContent,
|
|
};
|
|
localStorage.setItem(`email-template-draft-${id}`, JSON.stringify(draft));
|
|
}
|
|
}, [subjectLine, htmlContent, textContent, loading, template, id]);
|
|
|
|
// Preview rendering
|
|
const renderPreview = useCallback((html: string, data: Record<string, unknown>) => {
|
|
try {
|
|
const compiled = Handlebars.compile(html);
|
|
const rendered = compiled(data);
|
|
|
|
if (previewRef.current?.contentDocument) {
|
|
const doc = previewRef.current.contentDocument;
|
|
doc.open();
|
|
doc.write(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
padding: 20px;
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>${rendered}</body>
|
|
</html>
|
|
`);
|
|
doc.close();
|
|
}
|
|
|
|
setPreviewError(null);
|
|
} catch (error: any) {
|
|
setPreviewError(error.message);
|
|
|
|
if (previewRef.current?.contentDocument) {
|
|
const doc = previewRef.current.contentDocument;
|
|
doc.open();
|
|
doc.write(`
|
|
<div style="color: red; padding: 20px;">
|
|
<h3>Preview Error</h3>
|
|
<pre>${error.message}</pre>
|
|
</div>
|
|
`);
|
|
doc.close();
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Debounced preview
|
|
const debouncedPreview = useMemo(
|
|
() => debounce(renderPreview, 300),
|
|
[renderPreview]
|
|
);
|
|
|
|
// Update preview when HTML or sample data changes
|
|
useEffect(() => {
|
|
if (showPreview) {
|
|
debouncedPreview(htmlContent, sampleData);
|
|
}
|
|
}, [htmlContent, sampleData, showPreview, debouncedPreview]);
|
|
|
|
// Variable insertion
|
|
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
|
|
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
|
|
if (!textarea) return;
|
|
|
|
const start = textarea.selectionStart;
|
|
const end = textarea.selectionEnd;
|
|
const content = editorType === 'html' ? htmlContent : textContent;
|
|
|
|
const before = content.substring(0, start);
|
|
const after = content.substring(end);
|
|
const variable = `{{${variableKey}}}`;
|
|
const newContent = before + variable + after;
|
|
|
|
if (editorType === 'html') {
|
|
setHtmlContent(newContent);
|
|
} else {
|
|
setTextContent(newContent);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const newPos = start + variable.length;
|
|
textarea.selectionStart = newPos;
|
|
textarea.selectionEnd = newPos;
|
|
textarea.focus();
|
|
}, 0);
|
|
};
|
|
|
|
// Save template
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await api.put(`/api/email-templates/${id}`, {
|
|
subjectLine,
|
|
htmlContent,
|
|
textContent,
|
|
changeNotes: changeNotes || undefined,
|
|
});
|
|
|
|
message.success('Template saved successfully');
|
|
localStorage.removeItem(`email-template-draft-${id}`);
|
|
navigate('/app/email-templates');
|
|
} catch (error) {
|
|
message.error('Failed to save template');
|
|
} finally {
|
|
setSaving(false);
|
|
setSaveModalVisible(false);
|
|
}
|
|
};
|
|
|
|
// Test send
|
|
const handleTestSend = async () => {
|
|
if (!testRecipient) {
|
|
message.error('Please enter recipient email');
|
|
return;
|
|
}
|
|
|
|
setTestSending(true);
|
|
try {
|
|
await api.post(`/api/email-templates/${id}/test`, {
|
|
recipientEmail: testRecipient,
|
|
testData: sampleData,
|
|
});
|
|
|
|
message.success('Test email sent successfully');
|
|
setTestModalVisible(false);
|
|
} catch (error) {
|
|
message.error('Failed to send test email');
|
|
} finally {
|
|
setTestSending(false);
|
|
}
|
|
};
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
setSaveModalVisible(true);
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
|
e.preventDefault();
|
|
setShowPreview(!showPreview);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [showPreview]);
|
|
|
|
if (loading) {
|
|
return <div style={{ padding: 24 }}>Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
{/* Toolbar */}
|
|
<div
|
|
style={{
|
|
padding: '12px 24px',
|
|
borderBottom: '1px solid #f0f0f0',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
<Space>
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/email-templates')}>
|
|
Back
|
|
</Button>
|
|
<Title level={4} style={{ margin: 0 }}>
|
|
{template?.name}
|
|
</Title>
|
|
</Space>
|
|
|
|
<Space>
|
|
<Button icon={<EyeOutlined />} onClick={() => setShowPreview(!showPreview)}>
|
|
{showPreview ? 'Hide' : 'Show'} Preview
|
|
</Button>
|
|
<Button icon={<SendOutlined />} onClick={() => setTestModalVisible(true)}>
|
|
Send Test
|
|
</Button>
|
|
<Button type="primary" icon={<SaveOutlined />} onClick={() => setSaveModalVisible(true)}>
|
|
Save
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* Editor Area */}
|
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
{/* Left: Editors */}
|
|
<div
|
|
style={{
|
|
flex: showPreview ? 1 : 2,
|
|
padding: 24,
|
|
overflowY: 'auto',
|
|
borderRight: '1px solid #f0f0f0',
|
|
}}
|
|
>
|
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
{/* Subject Line */}
|
|
<div>
|
|
<Text strong>Subject Line</Text>
|
|
<Input
|
|
value={subjectLine}
|
|
onChange={(e) => setSubjectLine(e.target.value)}
|
|
placeholder="Enter subject line with {{VARIABLES}}"
|
|
/>
|
|
</div>
|
|
|
|
{/* HTML Editor */}
|
|
<div>
|
|
<Text strong>HTML Content</Text>
|
|
<TextArea
|
|
ref={htmlEditorRef}
|
|
value={htmlContent}
|
|
onChange={(e) => setHtmlContent(e.target.value)}
|
|
placeholder="Enter HTML content with {{VARIABLES}}"
|
|
rows={20}
|
|
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Text Editor */}
|
|
<div>
|
|
<Text strong>Plain Text Content</Text>
|
|
<TextArea
|
|
ref={textEditorRef}
|
|
value={textContent}
|
|
onChange={(e) => setTextContent(e.target.value)}
|
|
placeholder="Enter plain text version"
|
|
rows={15}
|
|
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
|
/>
|
|
</div>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* Right: Preview + Variables */}
|
|
{showPreview && (
|
|
<div style={{ flex: 1, padding: 24, overflowY: 'auto' }}>
|
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
|
{/* Variables Panel */}
|
|
<Card title="Variables" size="small">
|
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
|
{variables
|
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
.map((variable) => (
|
|
<Card key={variable.id} size="small" style={{ marginBottom: 8 }}>
|
|
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
|
<Space>
|
|
<Text strong>{variable.label}</Text>
|
|
{variable.isRequired && <Tag color="red">Required</Tag>}
|
|
{variable.isConditional && <Tag color="blue">Conditional</Tag>}
|
|
</Space>
|
|
|
|
{variable.description && (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{variable.description}
|
|
</Text>
|
|
)}
|
|
|
|
<Space size="small">
|
|
<Button size="small" onClick={() => handleInsertVariable(variable.key, 'html')}>
|
|
Insert to HTML
|
|
</Button>
|
|
<Button size="small" onClick={() => handleInsertVariable(variable.key, 'text')}>
|
|
Insert to Text
|
|
</Button>
|
|
</Space>
|
|
</Space>
|
|
</Card>
|
|
))}
|
|
</Space>
|
|
</Card>
|
|
|
|
{/* Preview */}
|
|
<Card title="Live Preview" size="small">
|
|
{previewError && (
|
|
<div style={{ color: 'red', marginBottom: 12 }}>
|
|
<Text strong>Error:</Text> {previewError}
|
|
</div>
|
|
)}
|
|
|
|
<iframe
|
|
ref={previewRef}
|
|
style={{
|
|
width: '100%',
|
|
height: 600,
|
|
border: '1px solid #d9d9d9',
|
|
borderRadius: 4,
|
|
}}
|
|
title="Email Preview"
|
|
/>
|
|
</Card>
|
|
</Space>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Save Modal */}
|
|
<Modal
|
|
title="Save Template"
|
|
visible={saveModalVisible}
|
|
onOk={handleSave}
|
|
onCancel={() => setSaveModalVisible(false)}
|
|
confirmLoading={saving}
|
|
okText="Save"
|
|
>
|
|
<Form layout="vertical">
|
|
<Form.Item label="Change Notes (optional)">
|
|
<TextArea
|
|
value={changeNotes}
|
|
onChange={(e) => setChangeNotes(e.target.value)}
|
|
placeholder="Describe what changed in this version"
|
|
rows={4}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Test Send Modal */}
|
|
<Modal
|
|
title="Send Test Email"
|
|
visible={testModalVisible}
|
|
onOk={handleTestSend}
|
|
onCancel={() => setTestModalVisible(false)}
|
|
confirmLoading={testSending}
|
|
okText="Send Test"
|
|
>
|
|
<Form layout="vertical">
|
|
<Form.Item label="Recipient Email" required>
|
|
<Input
|
|
type="email"
|
|
value={testRecipient}
|
|
onChange={(e) => setTestRecipient(e.target.value)}
|
|
placeholder="your-email@example.com"
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="Sample Data">
|
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
|
Using sample data from preview. Edit values in the preview panel to change test data.
|
|
</Text>
|
|
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4 }}>
|
|
{JSON.stringify(sampleData, null, 2)}
|
|
</pre>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Problem: Preview not updating
|
|
|
|
**Symptoms:**
|
|
- Type in HTML editor but preview doesn't change
|
|
- Preview shows old content
|
|
|
|
**Causes:**
|
|
1. Debounce timer still running (300ms delay)
|
|
2. Handlebars compilation error (silent failure)
|
|
3. Iframe not re-rendering
|
|
|
|
**Solutions:**
|
|
|
|
**Wait for debounce:**
|
|
- Wait 300ms after typing stops
|
|
- Preview should update automatically
|
|
|
|
**Check browser console:**
|
|
```javascript
|
|
// Look for errors
|
|
Handlebars.compile error: ...
|
|
```
|
|
|
|
**Force preview update:**
|
|
```typescript
|
|
// Add button to manually trigger preview
|
|
<Button onClick={() => renderPreview(htmlContent, sampleData)}>
|
|
Refresh Preview
|
|
</Button>
|
|
```
|
|
|
|
**Check iframe contentDocument:**
|
|
```typescript
|
|
console.log('Iframe doc:', previewRef.current?.contentDocument);
|
|
// Should not be null
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Test send fails
|
|
|
|
**Symptoms:**
|
|
- "Failed to send test email" error
|
|
- Email not received in inbox or MailHog
|
|
|
|
**Causes:**
|
|
1. SMTP configuration incorrect
|
|
2. Email test mode disabled (sending to real SMTP)
|
|
3. Recipient email invalid
|
|
4. Template has compilation errors
|
|
|
|
**Solutions:**
|
|
|
|
**Check SMTP settings:**
|
|
```bash
|
|
# .env
|
|
EMAIL_TEST_MODE=true # Use MailHog
|
|
```
|
|
|
|
**Verify MailHog running:**
|
|
```bash
|
|
docker compose ps mailhog
|
|
# Should show "Up"
|
|
```
|
|
|
|
**Check test logs:**
|
|
```sql
|
|
SELECT * FROM email_template_test_logs
|
|
WHERE template_id = 'xxx'
|
|
ORDER BY created_at DESC
|
|
LIMIT 5;
|
|
|
|
-- Look at error_message column
|
|
```
|
|
|
|
**Test with minimal template:**
|
|
```html
|
|
<p>Hello {{USER_NAME}}</p>
|
|
```
|
|
|
|
**Validate email address:**
|
|
```typescript
|
|
import validator from 'validator';
|
|
|
|
if (!validator.isEmail(testRecipient)) {
|
|
message.error('Invalid email address');
|
|
return;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Problem: Variable insertion doesn't work
|
|
|
|
**Symptoms:**
|
|
- Click "Insert to HTML" button but nothing happens
|
|
- Variable inserted in wrong location
|
|
|
|
**Causes:**
|
|
1. Textarea ref not set
|
|
2. Cursor position not captured correctly
|
|
3. State update timing issue
|
|
|
|
**Solutions:**
|
|
|
|
**Check ref exists:**
|
|
```typescript
|
|
console.log('HTML ref:', htmlEditorRef.current);
|
|
// Should be <textarea> element
|
|
```
|
|
|
|
**Debug cursor position:**
|
|
```typescript
|
|
const handleInsertVariable = (variableKey: string, editorType: 'html' | 'text') => {
|
|
const textarea = editorType === 'html' ? htmlEditorRef.current : textEditorRef.current;
|
|
console.log('Cursor position:', textarea?.selectionStart, textarea?.selectionEnd);
|
|
|
|
// Rest of insertion logic...
|
|
};
|
|
```
|
|
|
|
**Manual workaround:**
|
|
- Type `{{VARIABLE_KEY}}` manually instead of using button
|
|
|
|
---
|
|
|
|
### Problem: Draft not restored on reload
|
|
|
|
**Symptoms:**
|
|
- Unsaved changes lost after browser refresh
|
|
- No "Restored draft" message
|
|
|
|
**Causes:**
|
|
1. localStorage not available (private browsing)
|
|
2. Draft key mismatch
|
|
3. localStorage quota exceeded
|
|
|
|
**Solutions:**
|
|
|
|
**Check localStorage:**
|
|
```javascript
|
|
// Browser console
|
|
localStorage.getItem('email-template-draft-cuid123');
|
|
// Should return JSON string
|
|
```
|
|
|
|
**Verify draft key:**
|
|
```typescript
|
|
console.log('Draft key:', `email-template-draft-${id}`);
|
|
```
|
|
|
|
**Clear old drafts:**
|
|
```javascript
|
|
// Browser console
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key?.startsWith('email-template-draft-')) {
|
|
localStorage.removeItem(key);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Future Enhancements
|
|
|
|
### Monaco Editor Integration
|
|
|
|
**Current:** Basic HTML textarea
|
|
**Future:** Monaco Editor with syntax highlighting, IntelliSense, error detection
|
|
|
|
**Benefits:**
|
|
- Syntax highlighting for HTML
|
|
- Auto-completion for HTML tags and Handlebars syntax
|
|
- Error squiggles for invalid HTML
|
|
- Multi-cursor editing
|
|
- Code folding
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
import Editor from '@monaco-editor/react';
|
|
|
|
<Editor
|
|
height="600px"
|
|
language="html"
|
|
value={htmlContent}
|
|
onChange={(value) => setHtmlContent(value || '')}
|
|
options={{
|
|
minimap: { enabled: false },
|
|
lineNumbers: 'on',
|
|
wordWrap: 'on',
|
|
}}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
### Drag-Drop Block Builder
|
|
|
|
**Current:** Manual HTML editing
|
|
**Future:** Visual block builder (like GrapesJS)
|
|
|
|
**Benefits:**
|
|
- No HTML knowledge required
|
|
- Pre-built email blocks (header, footer, CTA button)
|
|
- Drag-drop interface
|
|
- Mobile-responsive by default
|
|
|
|
**Implementation:**
|
|
- Use GrapesJS (same as landing page editor)
|
|
- Custom blocks for email-safe components
|
|
- Export to HTML for template storage
|
|
|
|
---
|
|
|
|
### Email Client Previews
|
|
|
|
**Current:** Single iframe preview
|
|
**Future:** Multi-client previews (Gmail, Outlook, Apple Mail)
|
|
|
|
**Benefits:**
|
|
- Test rendering across email clients
|
|
- Catch client-specific CSS issues
|
|
- Preview dark mode rendering
|
|
|
|
**Services:**
|
|
- [Litmus](https://www.litmus.com/) API integration
|
|
- [Email on Acid](https://www.emailonacid.com/) screenshots
|
|
- Self-hosted preview using email client CSS emulation
|
|
|
|
---
|
|
|
|
### A/B Testing Support
|
|
|
|
**Current:** Single template version
|
|
**Future:** A/B testing with variant templates
|
|
|
|
**Features:**
|
|
- Create template variants (A, B, C)
|
|
- Split traffic across variants
|
|
- Track open rates, click rates
|
|
- Auto-promote winning variant
|
|
|
|
**Implementation:**
|
|
- EmailTemplateVariant model (templateId, variantName, weight, stats)
|
|
- Random variant selection on send
|
|
- Tracking pixel in email HTML
|
|
- Analytics dashboard
|
|
|
|
---
|
|
|
|
## Performance
|
|
|
|
### Auto-Save Timing
|
|
|
|
**Current Implementation:**
|
|
- Save to localStorage on blur (when focus leaves editor)
|
|
- No automatic interval-based saves
|
|
|
|
**Performance Impact:**
|
|
- Negligible (localStorage write is < 1ms)
|
|
- No network requests (local only)
|
|
|
|
**Alternative: Interval-Based Auto-Save:**
|
|
```typescript
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
if (htmlContent || textContent) {
|
|
localStorage.setItem(`email-template-draft-${id}`, JSON.stringify({
|
|
subjectLine,
|
|
htmlContent,
|
|
textContent,
|
|
savedAt: new Date().toISOString(),
|
|
}));
|
|
}
|
|
}, 10000); // Every 10 seconds
|
|
|
|
return () => clearInterval(interval);
|
|
}, [id, subjectLine, htmlContent, textContent]);
|
|
```
|
|
|
|
---
|
|
|
|
### Preview Rendering Performance
|
|
|
|
**Debounce Delay:**
|
|
- Current: 300ms
|
|
- Too short: Preview updates too frequently (distracting)
|
|
- Too long: Preview feels laggy
|
|
|
|
**Handlebars Compilation:**
|
|
- Fast (< 1ms for typical templates)
|
|
- May slow down for very large templates (> 100KB)
|
|
|
|
**Iframe Rendering:**
|
|
- Browser-native rendering (very fast)
|
|
- No performance concerns
|
|
|
|
**Optimization for Large Templates:**
|
|
```typescript
|
|
// Skip preview for very large HTML
|
|
const renderPreview = (html: string, data: Record<string, unknown>) => {
|
|
if (html.length > 100000) { // 100KB
|
|
setPreviewError('Template too large for live preview. Use test send instead.');
|
|
return;
|
|
}
|
|
|
|
// Normal preview rendering...
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Accessibility
|
|
|
|
### Keyboard Shortcuts
|
|
|
|
**Implemented:**
|
|
- `Ctrl+S` (or `Cmd+S` on Mac) — Save template
|
|
- `Ctrl+P` — Toggle preview pane
|
|
- `Esc` — Close modal
|
|
|
|
**Implementation:**
|
|
```typescript
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Save
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
setSaveModalVisible(true);
|
|
}
|
|
|
|
// Preview toggle
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
|
e.preventDefault();
|
|
setShowPreview(!showPreview);
|
|
}
|
|
|
|
// Close modal
|
|
if (e.key === 'Escape') {
|
|
setSaveModalVisible(false);
|
|
setTestModalVisible(false);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [showPreview]);
|
|
```
|
|
|
|
---
|
|
|
|
### Screen Reader Support
|
|
|
|
**Form Labels:**
|
|
```typescript
|
|
<Form.Item label="Recipient Email" required>
|
|
<Input
|
|
type="email"
|
|
aria-label="Test email recipient address"
|
|
aria-required="true"
|
|
value={testRecipient}
|
|
onChange={(e) => setTestRecipient(e.target.value)}
|
|
/>
|
|
</Form.Item>
|
|
```
|
|
|
|
**Button Descriptions:**
|
|
```typescript
|
|
<Button
|
|
icon={<SaveOutlined />}
|
|
onClick={() => setSaveModalVisible(true)}
|
|
aria-label="Save template and create new version"
|
|
>
|
|
Save
|
|
</Button>
|
|
|
|
<Button
|
|
size="small"
|
|
onClick={() => handleInsertVariable(variable.key, 'html')}
|
|
aria-label={`Insert ${variable.label} variable into HTML editor`}
|
|
>
|
|
Insert to HTML
|
|
</Button>
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
### Frontend Documentation
|
|
|
|
- **[EmailTemplatesPage.tsx](../../frontend/pages/email-templates-page.md)** — Email templates list page
|
|
- **[App.tsx](../../frontend/app.md)** — Route definition for editor page
|
|
|
|
### Backend Documentation
|
|
|
|
- **[Email Templates Module](../../api/modules/email-templates.md)** — API routes
|
|
- `GET /api/email-templates/:id` — Load template + variables
|
|
- `PUT /api/email-templates/:id` — Update template (creates version)
|
|
- `POST /api/email-templates/:id/test` — Send test email
|
|
|
|
### Feature Documentation
|
|
|
|
- **[template-system.md](./template-system.md)** — Email template engine overview
|
|
- **[variables.md](./variables.md)** — Template variable system
|
|
- **[versioning.md](./versioning.md)** — Template version history
|
|
|
|
### Related Features
|
|
|
|
- **[Landing Page Editor](../pages/editor.md)** — Similar GrapesJS editor for pages
|
|
- **[Campaign Emails](../influence/campaign-emails.md)** — Uses email templates for advocacy emails
|