feat: implement email template system with vendor overrides
Add comprehensive email template management for both admin and vendors:
Admin Features:
- Email templates management page at /admin/email-templates
- Edit platform templates with language support (en, fr, de, lb)
- Preview templates with sample variables
- Send test emails
- View email logs per template
Vendor Features:
- Email templates customization page at /vendor/{code}/email-templates
- Override platform templates with vendor-specific versions
- Preview and test customized templates
- Revert to platform defaults
Technical Changes:
- Migration for vendor_email_templates table
- VendorEmailTemplate model with override management
- Enhanced EmailService with language resolution chain
(customer preferred -> vendor preferred -> platform default)
- Branding resolution (Wizamart default, removed for whitelabel)
- Platform-only template protection (billing templates)
- Admin and vendor API endpoints with full CRUD
- Updated seed script with billing and team templates
Files: 22 changed, ~3,900 lines added
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
303
static/admin/js/email-templates.js
Normal file
303
static/admin/js/email-templates.js
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Email Templates Management Page
|
||||
*
|
||||
* Handles:
|
||||
* - Listing all platform email templates
|
||||
* - Editing template content (all languages)
|
||||
* - Preview and test email sending
|
||||
*/
|
||||
|
||||
function emailTemplatesPage() {
|
||||
return {
|
||||
// Data
|
||||
loading: true,
|
||||
templates: [],
|
||||
categories: [],
|
||||
selectedCategory: null,
|
||||
|
||||
// Edit Modal
|
||||
showEditModal: false,
|
||||
editingTemplate: null,
|
||||
editLanguage: 'en',
|
||||
loadingTemplate: false,
|
||||
editForm: {
|
||||
subject: '',
|
||||
body_html: '',
|
||||
body_text: '',
|
||||
variables: [],
|
||||
required_variables: []
|
||||
},
|
||||
saving: false,
|
||||
|
||||
// Preview Modal
|
||||
showPreviewModal: false,
|
||||
previewData: null,
|
||||
|
||||
// Test Email Modal
|
||||
showTestEmailModal: false,
|
||||
testEmailAddress: '',
|
||||
sendingTest: false,
|
||||
|
||||
// Computed
|
||||
get filteredTemplates() {
|
||||
if (!this.selectedCategory) {
|
||||
return this.templates;
|
||||
}
|
||||
return this.templates.filter(t => t.category === this.selectedCategory);
|
||||
},
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
await this.loadData();
|
||||
},
|
||||
|
||||
// Data Loading
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [templatesRes, categoriesRes] = await Promise.all([
|
||||
fetch('/api/v1/admin/email-templates'),
|
||||
fetch('/api/v1/admin/email-templates/categories')
|
||||
]);
|
||||
|
||||
if (templatesRes.ok) {
|
||||
this.templates = await templatesRes.json();
|
||||
}
|
||||
|
||||
if (categoriesRes.ok) {
|
||||
const data = await categoriesRes.json();
|
||||
this.categories = data.categories || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load email templates:', error);
|
||||
this.showNotification('Failed to load templates', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Category styling
|
||||
getCategoryClass(category) {
|
||||
const classes = {
|
||||
'auth': 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200',
|
||||
'orders': 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200',
|
||||
'billing': 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200',
|
||||
'system': 'bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200',
|
||||
'marketing': 'bg-pink-100 dark:bg-pink-900 text-pink-800 dark:text-pink-200'
|
||||
};
|
||||
return classes[category] || 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200';
|
||||
},
|
||||
|
||||
// Edit Template
|
||||
async editTemplate(template) {
|
||||
this.editingTemplate = template;
|
||||
this.editLanguage = 'en';
|
||||
this.showEditModal = true;
|
||||
await this.loadTemplateLanguage();
|
||||
},
|
||||
|
||||
async loadTemplateLanguage() {
|
||||
if (!this.editingTemplate) return;
|
||||
|
||||
this.loadingTemplate = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.editForm = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
variables: data.variables || [],
|
||||
required_variables: data.required_variables || []
|
||||
};
|
||||
} else if (response.status === 404) {
|
||||
// Template doesn't exist for this language yet
|
||||
this.editForm = {
|
||||
subject: '',
|
||||
body_html: '',
|
||||
body_text: '',
|
||||
variables: [],
|
||||
required_variables: []
|
||||
};
|
||||
this.showNotification(`No template for ${this.editLanguage.toUpperCase()} - create one by saving`, 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
this.showNotification('Failed to load template', 'error');
|
||||
} finally {
|
||||
this.loadingTemplate = false;
|
||||
}
|
||||
},
|
||||
|
||||
closeEditModal() {
|
||||
this.showEditModal = false;
|
||||
this.editingTemplate = null;
|
||||
this.editForm = {
|
||||
subject: '',
|
||||
body_html: '',
|
||||
body_text: '',
|
||||
variables: [],
|
||||
required_variables: []
|
||||
};
|
||||
},
|
||||
|
||||
async saveTemplate() {
|
||||
if (!this.editingTemplate) return;
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${this.editingTemplate.code}/${this.editLanguage}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
subject: this.editForm.subject,
|
||||
body_html: this.editForm.body_html,
|
||||
body_text: this.editForm.body_text
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.showNotification('Template saved successfully', 'success');
|
||||
// Refresh templates list
|
||||
await this.loadData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
this.showNotification(error.detail || 'Failed to save template', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save template:', error);
|
||||
this.showNotification('Failed to save template', 'error');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Preview
|
||||
async previewTemplate(template) {
|
||||
try {
|
||||
// Use sample variables for preview
|
||||
const sampleVariables = this.getSampleVariables(template.code);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${template.code}/preview`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_code: template.code,
|
||||
language: 'en',
|
||||
variables: sampleVariables
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
this.previewData = await response.json();
|
||||
this.showPreviewModal = true;
|
||||
} else {
|
||||
this.showNotification('Failed to load preview', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to preview template:', error);
|
||||
this.showNotification('Failed to load preview', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
getSampleVariables(templateCode) {
|
||||
// Sample variables for common templates
|
||||
const samples = {
|
||||
'signup_welcome': {
|
||||
first_name: 'John',
|
||||
company_name: 'Acme Corp',
|
||||
email: 'john@example.com',
|
||||
vendor_code: 'acme',
|
||||
login_url: 'https://example.com/login',
|
||||
trial_days: '14',
|
||||
tier_name: 'Business'
|
||||
},
|
||||
'order_confirmation': {
|
||||
customer_name: 'Jane Doe',
|
||||
order_number: 'ORD-12345',
|
||||
order_total: '99.99',
|
||||
order_items_count: '3',
|
||||
order_date: '2024-01-15',
|
||||
shipping_address: '123 Main St, Luxembourg City, L-1234'
|
||||
},
|
||||
'password_reset': {
|
||||
customer_name: 'John Doe',
|
||||
reset_link: 'https://example.com/reset?token=abc123',
|
||||
expiry_hours: '1'
|
||||
},
|
||||
'team_invite': {
|
||||
invitee_name: 'Jane',
|
||||
inviter_name: 'John',
|
||||
vendor_name: 'Acme Corp',
|
||||
role: 'Admin',
|
||||
accept_url: 'https://example.com/accept',
|
||||
expires_in_days: '7'
|
||||
}
|
||||
};
|
||||
return samples[templateCode] || { platform_name: 'Wizamart' };
|
||||
},
|
||||
|
||||
// Test Email
|
||||
sendTestEmail() {
|
||||
this.showTestEmailModal = true;
|
||||
},
|
||||
|
||||
async confirmSendTestEmail() {
|
||||
if (!this.testEmailAddress || !this.editingTemplate) return;
|
||||
|
||||
this.sendingTest = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/admin/email-templates/${this.editingTemplate.code}/test`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_code: this.editingTemplate.code,
|
||||
language: this.editLanguage,
|
||||
to_email: this.testEmailAddress,
|
||||
variables: this.getSampleVariables(this.editingTemplate.code)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
this.showNotification(`Test email sent to ${this.testEmailAddress}`, 'success');
|
||||
this.showTestEmailModal = false;
|
||||
this.testEmailAddress = '';
|
||||
} else {
|
||||
this.showNotification(result.message || 'Failed to send test email', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send test email:', error);
|
||||
this.showNotification('Failed to send test email', 'error');
|
||||
} finally {
|
||||
this.sendingTest = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Notifications
|
||||
showNotification(message, type = 'info') {
|
||||
// Use global notification system if available
|
||||
if (window.showToast) {
|
||||
window.showToast(message, type);
|
||||
} else if (window.Alpine && Alpine.store('notifications')) {
|
||||
Alpine.store('notifications').add(message, type);
|
||||
} else {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user