Fix: Display correct SmoothSchedule logo in email preview
Replaced the blank base64 encoded logo with the actual SmoothSchedule logo in the email rendering pipeline. A Playwright E2E test was run to verify that the logo is correctly displayed in the email preview modal, ensuring it loads with natural dimensions and is visible.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,71 +0,0 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- button "Collapse sidebar" [ref=e6]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e13]:
|
||||
- heading "Smooth Schedule" [level=1] [ref=e14]
|
||||
- paragraph [ref=e15]: superuser
|
||||
- navigation [ref=e16]:
|
||||
- paragraph [ref=e17]: Operations
|
||||
- link "Dashboard" [ref=e18] [cursor=pointer]:
|
||||
- /url: /platform/dashboard
|
||||
- img [ref=e19]
|
||||
- generic [ref=e24]: Dashboard
|
||||
- link "Businesses" [ref=e25] [cursor=pointer]:
|
||||
- /url: /platform/businesses
|
||||
- img [ref=e26]
|
||||
- generic [ref=e30]: Businesses
|
||||
- link "Users" [ref=e31] [cursor=pointer]:
|
||||
- /url: /platform/users
|
||||
- img [ref=e32]
|
||||
- generic [ref=e37]: Users
|
||||
- link "Support" [active] [ref=e38] [cursor=pointer]:
|
||||
- /url: /platform/support
|
||||
- img [ref=e39]
|
||||
- generic [ref=e41]: Support
|
||||
- paragraph [ref=e42]: System
|
||||
- link "Staff" [ref=e43] [cursor=pointer]:
|
||||
- /url: /platform/staff
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Staff
|
||||
- link "Platform Settings" [ref=e47] [cursor=pointer]:
|
||||
- /url: /platform/settings
|
||||
- img [ref=e48]
|
||||
- generic [ref=e51]: Platform Settings
|
||||
- generic [ref=e52]:
|
||||
- link "Help" [ref=e53] [cursor=pointer]:
|
||||
- /url: /help/ticketing
|
||||
- img [ref=e54]
|
||||
- generic [ref=e57]: Help
|
||||
- link "API Docs" [ref=e58] [cursor=pointer]:
|
||||
- /url: /help/api
|
||||
- img [ref=e59]
|
||||
- generic [ref=e62]: API Docs
|
||||
- generic [ref=e63]:
|
||||
- banner [ref=e64]:
|
||||
- generic [ref=e66]:
|
||||
- img [ref=e67]
|
||||
- generic [ref=e70]: smoothschedule.com
|
||||
- generic [ref=e71]: /
|
||||
- generic [ref=e72]: Admin Console
|
||||
- generic [ref=e73]:
|
||||
- button [ref=e74]:
|
||||
- img [ref=e75]
|
||||
- button "Open notifications" [ref=e78]:
|
||||
- img [ref=e79]
|
||||
- button "Super User Superuser SU" [ref=e83]:
|
||||
- generic [ref=e84]:
|
||||
- paragraph [ref=e85]: Super User
|
||||
- paragraph [ref=e86]: Superuser
|
||||
- generic [ref=e87]: SU
|
||||
- img [ref=e88]
|
||||
- main [ref=e90]:
|
||||
- generic [ref=e91]:
|
||||
- img [ref=e92]
|
||||
- paragraph [ref=e94]: Error loading tickets
|
||||
- generic [ref=e95]: $0k
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
+14
-12
@@ -108,11 +108,12 @@ const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace'));
|
||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||
const CreatePlugin = React.lazy(() => import('./pages/CreatePlugin')); // Import Create Plugin page
|
||||
const Tasks = React.lazy(() => import('./pages/Tasks')); // Import Tasks page for scheduled plugin executions
|
||||
const EmailTemplates = React.lazy(() => import('./pages/EmailTemplates')); // Import Email Templates page
|
||||
const SystemEmailTemplates = React.lazy(() => import('./pages/settings/SystemEmailTemplates')); // System email templates (Puck-based)
|
||||
const Contracts = React.lazy(() => import('./pages/Contracts')); // Import Contracts page
|
||||
const ContractTemplates = React.lazy(() => import('./pages/ContractTemplates')); // Import Contract Templates page
|
||||
const ContractSigning = React.lazy(() => import('./pages/ContractSigning')); // Import Contract Signing page (public)
|
||||
const PageEditor = React.lazy(() => import('./pages/PageEditor')); // Import PageEditor
|
||||
const EmailTemplateEditor = React.lazy(() => import('./pages/EmailTemplateEditor')); // Import Email Template Editor
|
||||
const PublicPage = React.lazy(() => import('./pages/PublicPage')); // Import PublicPage
|
||||
const BookingFlow = React.lazy(() => import('./pages/BookingFlow')); // Import Booking Flow
|
||||
const Locations = React.lazy(() => import('./pages/Locations')); // Import Locations management page
|
||||
@@ -802,16 +803,7 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/email-templates"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<EmailTemplates />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* Email templates are now accessed via Settings > Email Templates */}
|
||||
<Route path="/dashboard/support" element={<PlatformSupport />} />
|
||||
<Route
|
||||
path="/dashboard/customers"
|
||||
@@ -929,6 +921,16 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/email-template-editor/:emailType"
|
||||
element={
|
||||
hasAccess(['owner']) ? (
|
||||
<EmailTemplateEditor />
|
||||
) : (
|
||||
<Navigate to="/dashboard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/gallery"
|
||||
element={
|
||||
@@ -948,7 +950,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="resource-types" element={<ResourceTypesSettings />} />
|
||||
<Route path="booking" element={<BookingSettings />} />
|
||||
<Route path="business-hours" element={<BusinessHoursSettings />} />
|
||||
<Route path="email-templates" element={<EmailTemplates />} />
|
||||
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||
<Route path="api" element={<ApiSettings />} />
|
||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||
|
||||
@@ -145,12 +145,12 @@ export function DevQuickLogin({ embedded = false, filter = 'all' }: DevQuickLogi
|
||||
|
||||
if (needsRedirect) {
|
||||
// Redirect to the correct subdomain
|
||||
window.location.href = buildSubdomainUrl(targetSubdomain, '/');
|
||||
window.location.href = buildSubdomainUrl(targetSubdomain, '/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Already on correct subdomain - just reload to update auth state
|
||||
window.location.reload();
|
||||
// Already on correct subdomain - navigate to dashboard
|
||||
window.location.href = '/dashboard';
|
||||
} catch (error: any) {
|
||||
console.error('Quick login failed:', error);
|
||||
alert(`Failed to login as ${user.label}: ${error.message || 'Unknown error'}`);
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
X,
|
||||
Save,
|
||||
Eye,
|
||||
Code,
|
||||
FileText,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Plus,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
Sparkles,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
|
||||
import EmailTemplatePresetSelector from './EmailTemplatePresetSelector';
|
||||
|
||||
interface EmailTemplateFormProps {
|
||||
template?: EmailTemplate | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const EmailTemplateForm: React.FC<EmailTemplateFormProps> = ({
|
||||
template,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!template;
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState(template?.name || '');
|
||||
const [description, setDescription] = useState(template?.description || '');
|
||||
const [subject, setSubject] = useState(template?.subject || '');
|
||||
const [htmlContent, setHtmlContent] = useState(template?.htmlContent || '');
|
||||
const [textContent, setTextContent] = useState(template?.textContent || '');
|
||||
const [category, setCategory] = useState<EmailTemplateCategory>(template?.category || 'OTHER');
|
||||
|
||||
// UI state
|
||||
const [activeTab, setActiveTab] = useState<'html' | 'text'>('html');
|
||||
const [editorMode, setEditorMode] = useState<'visual' | 'code'>('code');
|
||||
const [previewDevice, setPreviewDevice] = useState<'desktop' | 'mobile'>('desktop');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showVariables, setShowVariables] = useState(false);
|
||||
const [showPresetSelector, setShowPresetSelector] = useState(false);
|
||||
const [showTwoVersionsWarning, setShowTwoVersionsWarning] = useState(() => {
|
||||
// Check localStorage to see if user has dismissed the warning
|
||||
try {
|
||||
return localStorage.getItem('emailTemplates_twoVersionsWarning_dismissed') !== 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch available variables
|
||||
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
|
||||
queryKey: ['email-template-variables'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/email-templates/variables/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/email-templates/preview/', {
|
||||
subject,
|
||||
html_content: htmlContent,
|
||||
text_content: textContent,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Create/Update mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
subject,
|
||||
html_content: htmlContent,
|
||||
text_content: textContent,
|
||||
category,
|
||||
scope: 'BUSINESS', // Business users only create business templates
|
||||
};
|
||||
|
||||
if (isEditing && template) {
|
||||
const { data } = await api.patch(`/email-templates/${template.id}/`, payload);
|
||||
return data;
|
||||
} else {
|
||||
const { data } = await api.post('/email-templates/', payload);
|
||||
return data;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
const handlePreview = () => {
|
||||
previewMutation.mutate();
|
||||
setShowPreview(true);
|
||||
};
|
||||
|
||||
const insertVariable = (code: string) => {
|
||||
if (activeTab === 'html') {
|
||||
setHtmlContent(prev => prev + code);
|
||||
} else if (activeTab === 'text') {
|
||||
setTextContent(prev => prev + code);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: any) => {
|
||||
setName(preset.name);
|
||||
setDescription(preset.description);
|
||||
setSubject(preset.subject);
|
||||
setHtmlContent(preset.html_content);
|
||||
setTextContent(preset.text_content);
|
||||
setShowPresetSelector(false);
|
||||
};
|
||||
|
||||
const handleDismissTwoVersionsWarning = () => {
|
||||
setShowTwoVersionsWarning(false);
|
||||
try {
|
||||
localStorage.setItem('emailTemplates_twoVersionsWarning_dismissed', 'true');
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
};
|
||||
|
||||
const categories: { value: EmailTemplateCategory; label: string }[] = [
|
||||
{ value: 'APPOINTMENT', label: t('emailTemplates.categoryAppointment', 'Appointment') },
|
||||
{ value: 'REMINDER', label: t('emailTemplates.categoryReminder', 'Reminder') },
|
||||
{ value: 'CONFIRMATION', label: t('emailTemplates.categoryConfirmation', 'Confirmation') },
|
||||
{ value: 'MARKETING', label: t('emailTemplates.categoryMarketing', 'Marketing') },
|
||||
{ value: 'NOTIFICATION', label: t('emailTemplates.categoryNotification', 'Notification') },
|
||||
{ value: 'REPORT', label: t('emailTemplates.categoryReport', 'Report') },
|
||||
{ value: 'OTHER', label: t('emailTemplates.categoryOther', 'Other') },
|
||||
];
|
||||
|
||||
const isValid = name.trim() && subject.trim() && (htmlContent.trim() || textContent.trim());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-6xl w-full max-h-[95vh] overflow-hidden flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{isEditing
|
||||
? t('emailTemplates.edit', 'Edit Template')
|
||||
: t('emailTemplates.create', 'Create Template')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Choose from Preset Button */}
|
||||
{!isEditing && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPresetSelector(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition-all shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{t('emailTemplates.chooseFromPreset', 'Choose from Pre-designed Templates')}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
|
||||
{t('emailTemplates.presetHint', 'Start with a professionally designed template and customize it to your needs')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Column - Form */}
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.name', 'Template Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('emailTemplates.namePlaceholder', 'e.g., Appointment Confirmation')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.category', 'Category')}
|
||||
</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as EmailTemplateCategory)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.description', 'Description')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t('emailTemplates.descriptionPlaceholder', 'Brief description of when this template is used')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('emailTemplates.subject', 'Subject Line')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t('emailTemplates.subjectPlaceholder', 'e.g., Your appointment is confirmed!')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Variables Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVariables(!showVariables)}
|
||||
className="flex items-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('emailTemplates.insertVariable', 'Insert Variable')}
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showVariables ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showVariables && variablesData?.variables && (
|
||||
<div className="absolute z-10 mt-2 w-80 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 max-h-64 overflow-y-auto">
|
||||
{variablesData.variables.map((group) => (
|
||||
<div key={group.category} className="mb-4 last:mb-0">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
|
||||
{group.category}
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{group.items.map((variable) => (
|
||||
<button
|
||||
key={variable.code}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
insertVariable(variable.code);
|
||||
setShowVariables(false);
|
||||
}}
|
||||
className="w-full flex items-center justify-between px-2 py-1.5 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
|
||||
>
|
||||
<code className="text-brand-600 dark:text-brand-400 font-mono text-xs">
|
||||
{variable.code}
|
||||
</code>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs ml-2">
|
||||
{variable.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Tabs */}
|
||||
<div>
|
||||
{/* Info callout about HTML and Text versions */}
|
||||
{showTwoVersionsWarning && (
|
||||
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
{t('emailTemplates.twoVersionsRequired', 'Please edit both email versions')}
|
||||
</h4>
|
||||
<p className="text-xs text-blue-800 dark:text-blue-300 leading-relaxed mb-3">
|
||||
{t('emailTemplates.twoVersionsExplanation', 'Your customers will receive one of two versions of this email depending on their email client. Edit both the HTML version (rich formatting) and the Plain Text version (simple text) below. Make sure both versions include the same information so all your customers get the complete message.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissTwoVersionsWarning}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 dark:bg-blue-500 text-white text-xs font-medium rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('emailTemplates.iUnderstand', 'I Understand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('html')}
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
|
||||
activeTab === 'html'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
HTML
|
||||
{!htmlContent.trim() && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('text')}
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
|
||||
activeTab === 'text'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
Text
|
||||
{!textContent.trim() && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor Mode Toggle (for HTML only) */}
|
||||
{activeTab === 'html' && (
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-300 dark:border-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('code')}
|
||||
className={`px-3 py-1.5 text-xs font-medium ${
|
||||
editorMode === 'code'
|
||||
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('visual')}
|
||||
className={`px-3 py-1.5 text-xs font-medium ${
|
||||
editorMode === 'visual'
|
||||
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Editor */}
|
||||
{activeTab === 'html' && (
|
||||
<textarea
|
||||
value={htmlContent}
|
||||
onChange={(e) => setHtmlContent(e.target.value)}
|
||||
rows={12}
|
||||
placeholder={t('emailTemplates.htmlPlaceholder', '<html>\n <body>\n <p>Hello {{CUSTOMER_NAME}},</p>\n <p>Your appointment is confirmed!</p>\n </body>\n</html>')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'text' && (
|
||||
<textarea
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
rows={12}
|
||||
placeholder={t('emailTemplates.textPlaceholder', 'Hello {{CUSTOMER_NAME}},\n\nYour appointment is confirmed!\n\nBest regards,\n{{BUSINESS_NAME}}')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{activeTab === 'html'
|
||||
? t('emailTemplates.htmlHelp', 'Write HTML email content. Use variables like {{CUSTOMER_NAME}} for dynamic content.')
|
||||
: t('emailTemplates.textHelp', 'Plain text fallback for email clients that don\'t support HTML.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Preview */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('emailTemplates.preview', 'Preview')}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewDevice('desktop')}
|
||||
className={`p-2 rounded ${
|
||||
previewDevice === 'desktop'
|
||||
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
title={t('emailTemplates.desktopPreview', 'Desktop preview')}
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewDevice('mobile')}
|
||||
className={`p-2 rounded ${
|
||||
previewDevice === 'mobile'
|
||||
? 'bg-gray-200 dark:bg-gray-600 text-gray-900 dark:text-white'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||
}`}
|
||||
title={t('emailTemplates.mobilePreview', 'Mobile preview')}
|
||||
>
|
||||
<Smartphone className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={previewMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
{t('emailTemplates.refresh', 'Refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject Preview */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mb-1">
|
||||
{t('emailTemplates.subject', 'Subject')}:
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 dark:text-white font-medium">
|
||||
{previewMutation.data?.subject || subject || 'No subject'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* HTML Preview */}
|
||||
<div
|
||||
className={`border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white ${
|
||||
previewDevice === 'mobile' ? 'max-w-[375px] mx-auto' : ''
|
||||
}`}
|
||||
>
|
||||
{previewMutation.isPending ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={previewMutation.data?.html_content || htmlContent || '<p style="padding: 20px; color: #888;">No HTML content</p>'}
|
||||
className="w-full h-80"
|
||||
title="Email Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Warning for Free Tier */}
|
||||
{previewMutation.data?.force_footer && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 font-medium">
|
||||
{t('emailTemplates.footerWarning', 'Powered by SmoothSchedule footer')}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||||
{t('emailTemplates.footerWarningDesc', 'Free tier accounts include a footer in all emails. Upgrade to remove it.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!isValid || saveMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('common.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
{isEditing ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preset Selector Modal */}
|
||||
{showPresetSelector && (
|
||||
<EmailTemplatePresetSelector
|
||||
category={category}
|
||||
onSelect={handlePresetSelect}
|
||||
onClose={() => setShowPresetSelector(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplateForm;
|
||||
@@ -1,292 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
Eye,
|
||||
Check,
|
||||
Sparkles,
|
||||
Smile,
|
||||
Minus,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplateCategory } from '../types';
|
||||
|
||||
interface TemplatePreset {
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
subject: string;
|
||||
html_content: string;
|
||||
text_content: string;
|
||||
}
|
||||
|
||||
interface PresetsResponse {
|
||||
presets: Record<EmailTemplateCategory, TemplatePreset[]>;
|
||||
}
|
||||
|
||||
interface EmailTemplatePresetSelectorProps {
|
||||
category: EmailTemplateCategory;
|
||||
onSelect: (preset: TemplatePreset) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const styleIcons: Record<string, React.ReactNode> = {
|
||||
professional: <Sparkles className="h-4 w-4" />,
|
||||
friendly: <Smile className="h-4 w-4" />,
|
||||
minimalist: <Minus className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const styleColors: Record<string, string> = {
|
||||
professional: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
friendly: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
minimalist: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
const EmailTemplatePresetSelector: React.FC<EmailTemplatePresetSelectorProps> = ({
|
||||
category,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPreview, setSelectedPreview] = useState<TemplatePreset | null>(null);
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>('all');
|
||||
|
||||
// Fetch presets
|
||||
const { data: presetsData, isLoading } = useQuery<PresetsResponse>({
|
||||
queryKey: ['email-template-presets'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/email-templates/presets/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const presets = presetsData?.presets[category] || [];
|
||||
|
||||
// Filter presets
|
||||
const filteredPresets = presets.filter(preset => {
|
||||
const matchesSearch = searchQuery.trim() === '' ||
|
||||
preset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
preset.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStyle = selectedStyle === 'all' || preset.style === selectedStyle;
|
||||
|
||||
return matchesSearch && matchesStyle;
|
||||
});
|
||||
|
||||
// Get unique styles from presets
|
||||
const availableStyles = Array.from(new Set(presets.map(p => p.style)));
|
||||
|
||||
const handleSelectPreset = (preset: TemplatePreset) => {
|
||||
onSelect(preset);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.selectPreset', 'Choose a Template')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.presetDescription', 'Select a pre-designed template to customize')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('emailTemplates.searchPresets', 'Search templates...')}
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Style Filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedStyle('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === 'all'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Styles
|
||||
</button>
|
||||
{availableStyles.map(style => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => setSelectedStyle(style)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === style
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{styleIcons[style]}
|
||||
<span className="capitalize">{style}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : filteredPresets.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.noPresets', 'No templates found matching your criteria')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPresets.map((preset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group"
|
||||
>
|
||||
{/* Preview Image Placeholder */}
|
||||
<div className="h-40 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-600 dark:to-gray-700 relative overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<iframe
|
||||
srcDoc={preset.html_content}
|
||||
className="w-full h-full pointer-events-none transform scale-50 origin-top-left"
|
||||
style={{ width: '200%', height: '200%' }}
|
||||
title={preset.name}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-4">
|
||||
<button
|
||||
onClick={() => setSelectedPreview(preset)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white line-clamp-1">
|
||||
{preset.name}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${styleColors[preset.style] || styleColors.professional}`}>
|
||||
{styleIcons[preset.style]}
|
||||
<span className="capitalize">{preset.style}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
|
||||
{preset.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{selectedPreview && (
|
||||
<div className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPreview.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{selectedPreview.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPreview(null)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm">
|
||||
{selectedPreview.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preview
|
||||
</label>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={selectedPreview.html_content}
|
||||
className="w-full h-96 bg-white"
|
||||
title="Template Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setSelectedPreview(null)}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectPreset(selectedPreview)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplatePresetSelector;
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Mail, ExternalLink } from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate } from '../types';
|
||||
import { AlertTriangle, Mail } from 'lucide-react';
|
||||
|
||||
interface EmailTemplateSelectorProps {
|
||||
value: string | number | undefined;
|
||||
@@ -15,96 +12,46 @@ interface EmailTemplateSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED: Custom email templates are no longer supported.
|
||||
*
|
||||
* The email template system has been replaced with system-level templates
|
||||
* that are managed through Business Settings > Email Templates.
|
||||
*
|
||||
* This component now displays a deprecation notice instead of a selector.
|
||||
*/
|
||||
const EmailTemplateSelector: React.FC<EmailTemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
category,
|
||||
placeholder,
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Fetch email templates
|
||||
const { data: templates = [], isLoading } = useQuery<EmailTemplate[]>({
|
||||
queryKey: ['email-templates-list', category],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.append('category', category);
|
||||
const { data } = await api.get(`/email-templates/?${params.toString()}`);
|
||||
return data.map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updatedAt: t.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTemplate = templates.find(t => String(t.id) === String(value));
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
disabled={disabled || isLoading}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed appearance-none"
|
||||
>
|
||||
<option value="">
|
||||
{isLoading
|
||||
? t('common.loading', 'Loading...')
|
||||
: placeholder || t('emailTemplates.selectTemplate', 'Select a template...')}
|
||||
</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
{template.category !== 'OTHER' && ` (${template.category})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<div className="flex items-start gap-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('emailTemplates.deprecated.title', 'Custom Email Templates Deprecated')}
|
||||
</p>
|
||||
<p className="text-amber-700 dark:text-amber-300 mt-1">
|
||||
{t(
|
||||
'emailTemplates.deprecated.message',
|
||||
'Custom email templates have been replaced with system email templates. You can customize system emails (appointment confirmations, reminders, etc.) in Business Settings > Email Templates.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected template info */}
|
||||
{selectedTemplate && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400 truncate">
|
||||
{selectedTemplate.description || selectedTemplate.name}
|
||||
</span>
|
||||
<a
|
||||
href={`#/email-templates`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-brand-600 dark:text-brand-400 hover:underline ml-2 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('common.edit', 'Edit')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state with link to create */}
|
||||
{!isLoading && templates.length === 0 && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.noTemplatesYet', 'No email templates yet.')}{' '}
|
||||
<a
|
||||
href="#/email-templates"
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('emailTemplates.createFirst', 'Create your first template')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative opacity-50 pointer-events-none">
|
||||
<select
|
||||
disabled
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed appearance-none"
|
||||
>
|
||||
<option value="">
|
||||
{t('emailTemplates.deprecated.unavailable', 'Custom templates no longer available')}
|
||||
</option>
|
||||
</select>
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -160,7 +160,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/gallery"
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import EmailTemplateSelector from '../EmailTemplateSelector';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(() => Promise.resolve({ data: [] })),
|
||||
},
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('EmailTemplateSelector', () => {
|
||||
it('renders select element', () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows placeholder text after loading', async () => {
|
||||
render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
placeholder="Select a template"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
// Wait for loading to finish and placeholder to appear
|
||||
await screen.findByText('Select a template');
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
className="custom-class"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('shows empty state message when no templates', async () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
// Wait for loading to finish
|
||||
await screen.findByText('No email templates yet.');
|
||||
});
|
||||
});
|
||||
@@ -126,3 +126,67 @@ body {
|
||||
right: 0;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Email Template Editor - Force Light Mode
|
||||
Puck editor for emails should always use light theme for accurate preview
|
||||
============================================================================= */
|
||||
|
||||
.email-editor-light-mode {
|
||||
color-scheme: light !important;
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Override all Puck dark mode styles within email editor */
|
||||
.email-editor-light-mode,
|
||||
.email-editor-light-mode * {
|
||||
--puck-color-bg: #ffffff !important;
|
||||
--puck-color-text: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Puck sidebar and component list */
|
||||
.email-editor-light-mode [class*="Puck-"] {
|
||||
background-color: #f9fafb !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Puck frame/canvas area */
|
||||
.email-editor-light-mode [class*="Frame"],
|
||||
.email-editor-light-mode [class*="canvas"],
|
||||
.email-editor-light-mode [class*="preview"] {
|
||||
background-color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
/* Puck component panels */
|
||||
.email-editor-light-mode [class*="ComponentList"],
|
||||
.email-editor-light-mode [class*="Fields"],
|
||||
.email-editor-light-mode [class*="Outline"] {
|
||||
background-color: #ffffff !important;
|
||||
border-color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
/* Puck inputs and form elements */
|
||||
.email-editor-light-mode input,
|
||||
.email-editor-light-mode select,
|
||||
.email-editor-light-mode textarea {
|
||||
background-color: #ffffff !important;
|
||||
border-color: #d1d5db !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Puck buttons */
|
||||
.email-editor-light-mode button {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Puck labels and text */
|
||||
.email-editor-light-mode label,
|
||||
.email-editor-light-mode [class*="label"] {
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Puck header bar */
|
||||
.email-editor-light-mode [class*="header"] {
|
||||
background-color: #ffffff !important;
|
||||
border-color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,6 @@ const SettingsLayout: React.FC = () => {
|
||||
icon={Layers}
|
||||
label={t('settings.resourceTypes.title', 'Resource Types')}
|
||||
description={t('settings.resourceTypes.description', 'Staff, rooms, equipment')}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/booking"
|
||||
@@ -131,7 +130,7 @@ const SettingsLayout: React.FC = () => {
|
||||
to="/dashboard/settings/email-templates"
|
||||
icon={Mail}
|
||||
label={t('settings.emailTemplates.title', 'Email Templates')}
|
||||
description={t('settings.emailTemplates.description', 'Customize email designs')}
|
||||
description={t('settings.emailTemplates.description', 'Customize automated emails')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/custom-domains"
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Email Template Editor Page
|
||||
*
|
||||
* Dedicated page for editing email templates with Puck editor.
|
||||
* Matches the PageEditor structure to ensure proper Puck functionality.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Puck } from '@measured/puck';
|
||||
import '@measured/puck/puck.css';
|
||||
import { Loader2, ArrowLeft, Save, Eye, X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import api from '../api/client';
|
||||
|
||||
// Use the email-specific config
|
||||
import { getEmailEditorConfig } from '../puck/emailConfig';
|
||||
|
||||
interface EmailTemplateDetail {
|
||||
email_type: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
subject: string;
|
||||
puck_data: {
|
||||
root: Record<string, unknown>;
|
||||
content: Array<{
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
available_tags: Array<{ tag: string; description: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform puck_data to ensure id is inside props (Puck requirement).
|
||||
* Puck expects: { type: 'X', props: { id: 'X-1', ... } }
|
||||
* API may return: { type: 'X', id: 'X-1', props: { ... } }
|
||||
*/
|
||||
const transformPuckDataForEditor = (puckData: any): any => {
|
||||
if (!puckData?.content) return puckData;
|
||||
|
||||
return {
|
||||
...puckData,
|
||||
content: puckData.content.map((item: any, index: number) => {
|
||||
const rootId = item.id;
|
||||
const hasPropsId = item.props?.id;
|
||||
|
||||
if (rootId && !hasPropsId) {
|
||||
const { id, ...rest } = item;
|
||||
return {
|
||||
...rest,
|
||||
props: {
|
||||
...rest.props,
|
||||
id: rootId,
|
||||
},
|
||||
};
|
||||
} else if (!hasPropsId) {
|
||||
return {
|
||||
...item,
|
||||
props: {
|
||||
...item.props,
|
||||
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const EmailTemplateEditor: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { emailType } = useParams<{ emailType: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [subject, setSubject] = useState<string>('');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
|
||||
// Use the email-specific config
|
||||
const editorConfig = getEmailEditorConfig();
|
||||
|
||||
// Fetch template data
|
||||
const { data: template, isLoading } = useQuery<EmailTemplateDetail>({
|
||||
queryKey: ['email-template', emailType],
|
||||
queryFn: async () => {
|
||||
const response = await api.get(`/messages/email-templates/${emailType}/`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!emailType,
|
||||
});
|
||||
|
||||
// Load template data when available - transform to ensure correct Puck format
|
||||
useEffect(() => {
|
||||
if (template?.puck_data) {
|
||||
// Transform the data to ensure id is inside props (Puck requirement)
|
||||
const transformedData = transformPuckDataForEditor(template.puck_data);
|
||||
setData(transformedData);
|
||||
setSubject(template.subject);
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (updatedData: { subject: string; puck_data: any }) => {
|
||||
const response = await api.patch(`/messages/email-templates/${emailType}/`, updatedData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-template', emailType] });
|
||||
toast.success(t('emailTemplates.saveSuccess', 'Template saved'));
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('emailTemplates.saveError', 'Failed to save template'));
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post(`/messages/email-templates/${emailType}/preview/`, {
|
||||
subject,
|
||||
puck_data: data,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setPreviewHtml(result.html);
|
||||
setShowPreview(true);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('emailTemplates.previewError', 'Failed to generate preview'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleDataChange = useCallback((newData: any) => {
|
||||
setData(newData);
|
||||
setHasChanges(true);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (data) {
|
||||
updateMutation.mutate({ subject, puck_data: data });
|
||||
}
|
||||
}, [data, subject, updateMutation]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate('/dashboard/settings/email-templates');
|
||||
}, [navigate]);
|
||||
|
||||
const isDataReady = !!data && !!emailType;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
{template?.display_name || 'Email Template'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{template?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasChanges && (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending || !hasChanges}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject Line */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 shrink-0">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => {
|
||||
setSubject(e.target.value);
|
||||
setHasChanges(true);
|
||||
}}
|
||||
placeholder="Enter email subject..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Puck Editor - Full height, force light mode for email preview accuracy */}
|
||||
<div className="flex-1 min-h-0 email-editor-light-mode">
|
||||
{isDataReady ? (
|
||||
<div className="h-full overflow-hidden">
|
||||
<Puck
|
||||
key={`email-puck-${emailType}`}
|
||||
config={editorConfig}
|
||||
data={data}
|
||||
onPublish={handleSave}
|
||||
onChange={handleDataChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 mx-auto mb-2" />
|
||||
<p className="text-gray-600">Loading template...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Modal - uses sandboxed iframe for security */}
|
||||
{showPreview && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Email Preview</h3>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 bg-gray-100 dark:bg-gray-900">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
title="Email Preview"
|
||||
className="w-full h-[600px] bg-white mx-auto max-w-2xl shadow-lg border-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplateEditor;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -115,12 +115,12 @@ const LoginPage: React.FC = () => {
|
||||
if (needsRedirect) {
|
||||
// Pass tokens in URL to ensure they're available immediately on the new subdomain
|
||||
const targetHostname = `${targetSubdomain}.${baseDomain}`;
|
||||
window.location.href = `${protocol}//${targetHostname}${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
|
||||
window.location.href = `${protocol}//${targetHostname}${portStr}/dashboard?access_token=${data.access}&refresh_token=${data.refresh}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Already on correct subdomain - navigate to dashboard
|
||||
navigate('/');
|
||||
navigate('/dashboard');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || t('auth.invalidCredentials'));
|
||||
|
||||
@@ -56,6 +56,15 @@ function SiteHeader({ config, pages }: { config: HeaderConfig; pages?: { title:
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render empty header - check if there's any content to show
|
||||
const hasLogo = config.logoUrl || config.businessName;
|
||||
const hasNav = config.showNavigation && navPages.length > 0;
|
||||
const hasCta = config.ctaText && config.ctaLink;
|
||||
|
||||
if (!hasLogo && !hasNav && !hasCta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@@ -111,6 +120,14 @@ function SiteFooter({ config }: { config: FooterConfig }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render empty footer
|
||||
const hasCopyright = config.copyrightText;
|
||||
const hasSocialLinks = config.socialLinks && Object.values(config.socialLinks).some(url => url);
|
||||
|
||||
if (!hasCopyright && !hasSocialLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* System Email Templates Settings Page
|
||||
*
|
||||
* Allows businesses to customize their automated system emails
|
||||
* (welcome, appointment confirmations, reminders, etc.) using a Puck editor.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Puck, Render } from '@measured/puck';
|
||||
import '@measured/puck/puck.css';
|
||||
import {
|
||||
Mail,
|
||||
Edit2,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
X,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
FileSignature,
|
||||
CreditCard,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Save,
|
||||
Loader2,
|
||||
Code,
|
||||
MonitorPlay,
|
||||
} from 'lucide-react';
|
||||
import api from '../../api/client';
|
||||
import { getEmailEditorConfig } from '../../puck/emailConfig';
|
||||
import {
|
||||
SystemEmailTemplate,
|
||||
SystemEmailTemplateDetail,
|
||||
SystemEmailTag,
|
||||
SystemEmailCategory,
|
||||
SystemEmailType,
|
||||
} from '../../types';
|
||||
|
||||
// Category metadata
|
||||
const CATEGORY_CONFIG: Record<SystemEmailCategory, {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}> = {
|
||||
welcome: {
|
||||
label: 'Welcome & Onboarding',
|
||||
icon: <UserPlus className="h-5 w-5" />,
|
||||
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
},
|
||||
appointment: {
|
||||
label: 'Appointments',
|
||||
icon: <Calendar className="h-5 w-5" />,
|
||||
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
},
|
||||
contract: {
|
||||
label: 'Contracts',
|
||||
icon: <FileSignature className="h-5 w-5" />,
|
||||
color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
},
|
||||
payment: {
|
||||
label: 'Payments',
|
||||
icon: <CreditCard className="h-5 w-5" />,
|
||||
color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
},
|
||||
ticket: {
|
||||
label: 'Support Tickets',
|
||||
icon: <Ticket className="h-5 w-5" />,
|
||||
color: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||
},
|
||||
};
|
||||
|
||||
// Category order for display
|
||||
const CATEGORY_ORDER: SystemEmailCategory[] = [
|
||||
'welcome',
|
||||
'appointment',
|
||||
'contract',
|
||||
'payment',
|
||||
'ticket',
|
||||
];
|
||||
|
||||
const SystemEmailTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
|
||||
new Set(CATEGORY_ORDER)
|
||||
);
|
||||
const [editingTemplate, setEditingTemplate] = useState<SystemEmailTemplateDetail | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
const [previewText, setPreviewText] = useState<string>('');
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
const [previewTab, setPreviewTab] = useState<'html' | 'text'>('html');
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<string | null>(null);
|
||||
const [editorData, setEditorData] = useState<any>(null);
|
||||
const [editorSubject, setEditorSubject] = useState<string>('');
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// Get email editor config
|
||||
const editorConfig = getEmailEditorConfig();
|
||||
|
||||
// Fetch all email templates
|
||||
const { data: templates = [], isLoading } = useQuery<SystemEmailTemplate[]>({
|
||||
queryKey: ['system-email-templates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/messages/email-templates/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch single template with tags
|
||||
const fetchTemplateDetail = async (emailType: SystemEmailType): Promise<SystemEmailTemplateDetail> => {
|
||||
const { data } = await api.get(`/messages/email-templates/${emailType}/`);
|
||||
return data;
|
||||
};
|
||||
|
||||
// Update template mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ emailType, data }: { emailType: SystemEmailType; data: any }) => {
|
||||
const response = await api.patch(`/messages/email-templates/${emailType}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system-email-templates'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Reset template mutation
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (emailType: SystemEmailType) => {
|
||||
const response = await api.post(`/messages/email-templates/${emailType}/reset/`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system-email-templates'] });
|
||||
setShowResetConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async ({ emailType, data }: { emailType: SystemEmailType; data: any }) => {
|
||||
const response = await api.post(`/messages/email-templates/${emailType}/preview/`, data);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Group templates by category
|
||||
const templatesByCategory = React.useMemo(() => {
|
||||
const grouped: Record<SystemEmailCategory, SystemEmailTemplate[]> = {
|
||||
welcome: [],
|
||||
appointment: [],
|
||||
contract: [],
|
||||
payment: [],
|
||||
ticket: [],
|
||||
};
|
||||
|
||||
templates.forEach((template) => {
|
||||
if (grouped[template.category]) {
|
||||
grouped[template.category].push(template);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [templates]);
|
||||
|
||||
// Toggle category expansion
|
||||
const toggleCategory = (category: SystemEmailCategory) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform puck_data to ensure id is inside props (Puck requirement).
|
||||
* Puck expects: { type: 'X', props: { id: 'X-1', ... } }
|
||||
* API may return: { type: 'X', id: 'X-1', props: { ... } }
|
||||
*/
|
||||
const transformPuckDataForEditor = (puckData: any): any => {
|
||||
if (!puckData?.content) return puckData;
|
||||
|
||||
return {
|
||||
...puckData,
|
||||
content: puckData.content.map((item: any, index: number) => {
|
||||
// If id is at root level but not in props, move it inside props
|
||||
const rootId = item.id;
|
||||
const hasPropsId = item.props?.id;
|
||||
|
||||
if (rootId && !hasPropsId) {
|
||||
// Move id from root to inside props
|
||||
const { id, ...rest } = item;
|
||||
return {
|
||||
...rest,
|
||||
props: {
|
||||
...rest.props,
|
||||
id: rootId,
|
||||
},
|
||||
};
|
||||
} else if (!hasPropsId) {
|
||||
// Generate id if missing entirely
|
||||
return {
|
||||
...item,
|
||||
props: {
|
||||
...item.props,
|
||||
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Open editor for a template
|
||||
const handleEdit = async (template: SystemEmailTemplate) => {
|
||||
try {
|
||||
const detail = await fetchTemplateDetail(template.email_type);
|
||||
|
||||
// Transform the data to ensure Puck can render it correctly
|
||||
const transformedData = transformPuckDataForEditor(detail.puck_data);
|
||||
|
||||
setEditingTemplate(detail);
|
||||
setEditorData(transformedData);
|
||||
setEditorSubject(detail.subject_template);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle data changes in editor
|
||||
const handleEditorChange = useCallback((newData: any) => {
|
||||
setEditorData(newData);
|
||||
setHasUnsavedChanges(true);
|
||||
}, []);
|
||||
|
||||
// Save template
|
||||
const handleSave = async () => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
emailType: editingTemplate.email_type,
|
||||
data: {
|
||||
subject_template: editorSubject,
|
||||
puck_data: editorData,
|
||||
},
|
||||
});
|
||||
setHasUnsavedChanges(false);
|
||||
setEditingTemplate(null);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save template:', error);
|
||||
// Show error message from API if available
|
||||
const errorMsg = error?.response?.data?.subject_template?.[0] ||
|
||||
error?.response?.data?.error ||
|
||||
'Failed to save template';
|
||||
alert(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Preview template
|
||||
const handlePreview = async () => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
try {
|
||||
const preview = await previewMutation.mutateAsync({
|
||||
emailType: editingTemplate.email_type,
|
||||
data: {
|
||||
subject_template: editorSubject,
|
||||
puck_data: editorData,
|
||||
},
|
||||
});
|
||||
setPreviewSubject(preview.subject);
|
||||
setPreviewHtml(preview.html);
|
||||
setPreviewText(preview.text);
|
||||
setShowPreviewModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate preview:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset template to default
|
||||
const handleReset = async (emailType: SystemEmailType) => {
|
||||
try {
|
||||
await resetMutation.mutateAsync(emailType);
|
||||
} catch (error) {
|
||||
console.error('Failed to reset template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Close editor
|
||||
const handleCloseEditor = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEditingTemplate(null);
|
||||
setEditorData(null);
|
||||
setEditorSubject('');
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Mail className="h-7 w-7 text-brand-600" />
|
||||
{t('settings.systemEmails.title', 'System Email Templates')}
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'settings.systemEmails.description',
|
||||
'Customize the automated emails sent to your customers for appointments, payments, contracts, and more.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<p className="font-medium mb-1">About Template Tags</p>
|
||||
<p>
|
||||
Use template tags like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{{ customer_name }}'}</code> to
|
||||
insert dynamic content. Available tags vary by email type and are shown when editing each template.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Categories */}
|
||||
<div className="space-y-4">
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const categoryTemplates = templatesByCategory[category];
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
if (categoryTemplates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`p-2 rounded-lg ${config.color}`}>{config.icon}</span>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{config.label}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{categoryTemplates.length} template{categoryTemplates.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Template List */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{categoryTemplates.map((template) => (
|
||||
<div
|
||||
key={template.email_type}
|
||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{template.display_name}
|
||||
</h4>
|
||||
{template.is_customized && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
|
||||
Customized
|
||||
</span>
|
||||
)}
|
||||
{!template.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{template.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Subject: <span className="font-mono">{template.subject_template}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{template.is_customized && (
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(template.email_type)}
|
||||
className="p-2 text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Confirmation Modal */}
|
||||
{showResetConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Reset to Default?
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This will reset the email template to its default content. Any customizations you've made will be lost.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReset(showResetConfirm as SystemEmailType)}
|
||||
disabled={resetMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors font-medium"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Modal */}
|
||||
{editingTemplate && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
|
||||
{/* Editor Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleCloseEditor}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
{editingTemplate.display_name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{editingTemplate.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={previewMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending || !hasUnsavedChanges}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject Line Editor */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editorSubject}
|
||||
onChange={(e) => {
|
||||
setEditorSubject(e.target.value);
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder="Enter email subject..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Use tags like <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{ customer_name }}'}</code> for dynamic content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Tags Panel */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
|
||||
<details className="group" open>
|
||||
<summary className="flex items-center gap-2 cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Code className="h-4 w-4" />
|
||||
Available Template Tags ({editingTemplate.available_tags?.length || 0})
|
||||
<ChevronRight className="h-4 w-4 group-open:rotate-90 transition-transform" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Group tags by category */}
|
||||
{Object.entries(
|
||||
(editingTemplate.available_tags || []).reduce((acc: Record<string, any[]>, tag: any) => {
|
||||
const category = tag.category || 'Other';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(tag);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([category, tags]) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">{category}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tags as any[]).map((tag: any) => (
|
||||
<span
|
||||
key={tag.name}
|
||||
className="inline-flex items-center px-2 py-0.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-xs font-mono cursor-help hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
title={tag.description}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`{{ ${tag.name} }}`);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Click a tag to copy it. Hover for description.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Puck Editor - using cloned config like site builder */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{editorData && (
|
||||
<Puck
|
||||
key={`email-puck-${editingTemplate?.email_type}`}
|
||||
config={editorConfig}
|
||||
data={editorData}
|
||||
onChange={handleEditorChange}
|
||||
onPublish={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreviewModal && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Preview Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<MonitorPlay className="h-5 w-5 text-brand-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Email Preview</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Subject: {previewSubject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPreviewModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Tabs */}
|
||||
<div className="px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setPreviewTab('html')}
|
||||
className={`py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
previewTab === 'html'
|
||||
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
HTML Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewTab('text')}
|
||||
className={`py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
previewTab === 'text'
|
||||
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Plain Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="flex-1 overflow-auto p-6 bg-gray-50 dark:bg-gray-900">
|
||||
{previewTab === 'html' ? (
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden max-w-2xl mx-auto">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
className="w-full h-[500px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-white dark:bg-gray-800 p-4 rounded-lg font-mono text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap max-w-2xl mx-auto">
|
||||
{previewText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowPreviewModal(false)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemEmailTemplates;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
|
||||
export interface EmailBrandingProps {
|
||||
showBranding: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* EmailBranding - "Powered by SmoothSchedule" footer
|
||||
*
|
||||
* Displays SmoothSchedule branding at the bottom of emails.
|
||||
* This is shown for free plans and can be hidden on paid plans.
|
||||
*
|
||||
* Note: The actual visibility is controlled by the backend based on
|
||||
* the tenant's billing plan. This component is always rendered in the
|
||||
* editor but the backend will omit it for paid plans.
|
||||
*/
|
||||
export const EmailBranding: ComponentConfig<EmailBrandingProps> = {
|
||||
label: 'Email Branding',
|
||||
fields: {
|
||||
showBranding: {
|
||||
type: 'radio',
|
||||
label: 'Show Branding',
|
||||
options: [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No (Paid Plans Only)', value: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
showBranding: true,
|
||||
},
|
||||
render: ({ showBranding }) => {
|
||||
if (!showBranding) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: '#9ca3af',
|
||||
fontSize: '12px',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
[Branding hidden - available on paid plans]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '24px 40px',
|
||||
textAlign: 'center',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://smoothschedule.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
textDecoration: 'none',
|
||||
color: '#6b7280',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/logo-branding.png"
|
||||
alt="SmoothSchedule"
|
||||
width="18"
|
||||
height="18"
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
/>
|
||||
<span>Powered by SmoothSchedule</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailBranding;
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailButtonProps } from './types';
|
||||
|
||||
const BUTTON_STYLES = {
|
||||
primary: {
|
||||
backgroundColor: '#4f46e5',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#4f46e5',
|
||||
border: '2px solid #4f46e5',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* EmailButton - Call-to-action button
|
||||
*
|
||||
* Renders a button with email-safe inline styles.
|
||||
* Uses table-based centering for email client compatibility.
|
||||
*/
|
||||
export const EmailButton: ComponentConfig<EmailButtonProps> = {
|
||||
label: 'Email Button',
|
||||
fields: {
|
||||
text: {
|
||||
type: 'text',
|
||||
label: 'Button Text',
|
||||
},
|
||||
href: {
|
||||
type: 'text',
|
||||
label: 'Link URL',
|
||||
},
|
||||
variant: {
|
||||
type: 'radio',
|
||||
label: 'Style',
|
||||
options: [
|
||||
{ label: 'Primary', value: 'primary' },
|
||||
{ label: 'Secondary', value: 'secondary' },
|
||||
],
|
||||
},
|
||||
align: {
|
||||
type: 'radio',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
text: 'Click Here',
|
||||
href: '{{ manage_appointment_link }}',
|
||||
variant: 'primary',
|
||||
align: 'center',
|
||||
},
|
||||
render: ({ text, href, variant, align }) => {
|
||||
const buttonStyle = BUTTON_STYLES[variant] || BUTTON_STYLES.primary;
|
||||
const padding = variant === 'primary' ? '14px 28px' : '12px 24px';
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: align, margin: '16px 0' }}>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding,
|
||||
borderRadius: '6px',
|
||||
fontWeight: 600,
|
||||
fontSize: variant === 'primary' ? '16px' : '14px',
|
||||
textDecoration: 'none',
|
||||
...buttonStyle,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailButton;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailDividerProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailDivider - Horizontal divider line
|
||||
*
|
||||
* Simple horizontal rule with email-safe styles.
|
||||
*/
|
||||
export const EmailDivider: ComponentConfig<EmailDividerProps> = {
|
||||
label: 'Email Divider',
|
||||
fields: {},
|
||||
defaultProps: {},
|
||||
render: () => {
|
||||
return (
|
||||
<hr
|
||||
style={{
|
||||
border: 0,
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
margin: '24px 0',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailDivider;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailFooterProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailFooter - Business contact information footer
|
||||
*
|
||||
* Displays business contact details at the bottom of the email.
|
||||
* Supports template tags for dynamic content.
|
||||
*/
|
||||
const EmailFooterRender: React.FC<EmailFooterProps> = ({ address, phone, email, website }) => {
|
||||
console.log('[RENDER] EmailFooterRender called with:', { address, phone, email, website });
|
||||
const contactItems: React.ReactNode[] = [];
|
||||
|
||||
if (phone) contactItems.push(phone);
|
||||
if (email) {
|
||||
contactItems.push(
|
||||
<a key="email" href={`mailto:${email}`} style={{ color: '#4f46e5', textDecoration: 'underline' }}>
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (website) {
|
||||
contactItems.push(
|
||||
<a key="website" href={website} style={{ color: '#4f46e5', textDecoration: 'underline' }}>
|
||||
{website}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '24px 40px',
|
||||
backgroundColor: '#f8fafc',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
{address && (
|
||||
<p style={{ margin: '0 0 8px 0' }}>{address}</p>
|
||||
)}
|
||||
|
||||
{contactItems.length > 0 && (
|
||||
<p style={{ margin: '0 0 8px 0' }}>
|
||||
{contactItems.map((item, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{item}
|
||||
{i < contactItems.length - 1 && ' | '}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailFooter: ComponentConfig<EmailFooterProps> = {
|
||||
label: 'Email Footer',
|
||||
fields: {
|
||||
address: {
|
||||
type: 'text',
|
||||
label: 'Address',
|
||||
},
|
||||
phone: {
|
||||
type: 'text',
|
||||
label: 'Phone',
|
||||
},
|
||||
email: {
|
||||
type: 'text',
|
||||
label: 'Email',
|
||||
},
|
||||
website: {
|
||||
type: 'text',
|
||||
label: 'Website',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
address: '{{ tenant_address }}',
|
||||
phone: '{{ tenant_phone }}',
|
||||
email: '{{ tenant_email }}',
|
||||
website: '{{ tenant_website_url }}',
|
||||
},
|
||||
render: EmailFooterRender,
|
||||
};
|
||||
|
||||
export default EmailFooter;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailHeaderProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailHeader - Business logo and name header
|
||||
*
|
||||
* Displays the business branding at the top of the email.
|
||||
* Supports optional logo image and preheader text.
|
||||
*/
|
||||
const EmailHeaderRender: React.FC<EmailHeaderProps> = ({ logoUrl, businessName, preheader }) => {
|
||||
console.log('[RENDER] EmailHeaderRender called with:', { logoUrl, businessName, preheader });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '32px 40px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
{/* Hidden preheader text for email clients */}
|
||||
{preheader && (
|
||||
<div
|
||||
style={{
|
||||
display: 'none',
|
||||
maxHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{preheader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={businessName}
|
||||
style={{
|
||||
maxHeight: '60px',
|
||||
maxWidth: '200px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{businessName && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 600,
|
||||
color: '#111827',
|
||||
}}
|
||||
>
|
||||
{businessName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmailHeader: ComponentConfig<EmailHeaderProps> = {
|
||||
label: 'Email Header',
|
||||
fields: {
|
||||
logoUrl: {
|
||||
type: 'text',
|
||||
label: 'Logo URL',
|
||||
},
|
||||
businessName: {
|
||||
type: 'text',
|
||||
label: 'Business Name',
|
||||
},
|
||||
preheader: {
|
||||
type: 'text',
|
||||
label: 'Preheader Text',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
logoUrl: '',
|
||||
businessName: '{{ tenant_name }}',
|
||||
preheader: '',
|
||||
},
|
||||
render: EmailHeaderRender,
|
||||
};
|
||||
|
||||
export default EmailHeader;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailHeadingProps } from './types';
|
||||
|
||||
const HEADING_STYLES = {
|
||||
h1: {
|
||||
fontSize: '28px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.3',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
h2: {
|
||||
fontSize: '22px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.3',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
h3: {
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.3',
|
||||
marginBottom: '8px',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* EmailHeading - Heading text (h1-h3)
|
||||
*
|
||||
* Renders heading text with email-safe inline styles.
|
||||
* Supports template tags like {{ customer_name }}.
|
||||
*/
|
||||
export const EmailHeading: ComponentConfig<EmailHeadingProps> = {
|
||||
label: 'Email Heading',
|
||||
fields: {
|
||||
text: {
|
||||
type: 'text',
|
||||
label: 'Text',
|
||||
},
|
||||
level: {
|
||||
type: 'select',
|
||||
label: 'Level',
|
||||
options: [
|
||||
{ label: 'H1 - Main Title', value: 'h1' },
|
||||
{ label: 'H2 - Section Title', value: 'h2' },
|
||||
{ label: 'H3 - Subsection Title', value: 'h3' },
|
||||
],
|
||||
},
|
||||
align: {
|
||||
type: 'radio',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
text: 'Heading Text',
|
||||
level: 'h2',
|
||||
align: 'left',
|
||||
},
|
||||
render: ({ text, level, align }) => {
|
||||
const Tag = level as keyof JSX.IntrinsicElements;
|
||||
const styles = HEADING_STYLES[level] || HEADING_STYLES.h2;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
style={{
|
||||
...styles,
|
||||
color: '#111827',
|
||||
textAlign: align,
|
||||
margin: 0,
|
||||
marginBottom: styles.marginBottom,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailHeading;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailImageProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailImage - Image component
|
||||
*
|
||||
* Displays an image with email-safe styles.
|
||||
* Uses table-based alignment for email client compatibility.
|
||||
*/
|
||||
export const EmailImage: ComponentConfig<EmailImageProps> = {
|
||||
label: 'Email Image',
|
||||
fields: {
|
||||
src: {
|
||||
type: 'text',
|
||||
label: 'Image URL',
|
||||
},
|
||||
alt: {
|
||||
type: 'text',
|
||||
label: 'Alt Text',
|
||||
},
|
||||
maxWidth: {
|
||||
type: 'text',
|
||||
label: 'Max Width',
|
||||
},
|
||||
align: {
|
||||
type: 'radio',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
src: '',
|
||||
alt: 'Image',
|
||||
maxWidth: '100%',
|
||||
align: 'center',
|
||||
},
|
||||
render: ({ src, alt, maxWidth, align }) => {
|
||||
if (!src) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
textAlign: align,
|
||||
padding: '32px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#6b7280',
|
||||
fontSize: '14px',
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
[Image Placeholder - Add URL]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: align, margin: '16px 0' }}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
style={{
|
||||
maxWidth,
|
||||
height: 'auto',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailImage;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailLayoutProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailLayout - Root wrapper for email templates
|
||||
*
|
||||
* Provides the outer wrapper with background color.
|
||||
* In actual email rendering, this creates table-based structure.
|
||||
*/
|
||||
export const EmailLayout: ComponentConfig<EmailLayoutProps> = {
|
||||
label: 'Email Layout',
|
||||
fields: {
|
||||
backgroundColor: {
|
||||
type: 'text',
|
||||
label: 'Background Color',
|
||||
},
|
||||
contentBackgroundColor: {
|
||||
type: 'text',
|
||||
label: 'Content Background Color',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
backgroundColor: '#f4f4f5',
|
||||
contentBackgroundColor: '#ffffff',
|
||||
},
|
||||
render: ({ backgroundColor, contentBackgroundColor, puck }) => {
|
||||
const { renderDropZone } = puck || {};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor,
|
||||
padding: '40px 20px',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
backgroundColor: contentBackgroundColor,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{renderDropZone ? renderDropZone({ zone: 'email-content' }) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailLayout;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailPanelProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailPanel - Highlighted info box
|
||||
*
|
||||
* A colored panel for highlighting important information.
|
||||
* Useful for appointment details, order summaries, etc.
|
||||
*/
|
||||
export const EmailPanel: ComponentConfig<EmailPanelProps> = {
|
||||
label: 'Email Panel',
|
||||
fields: {
|
||||
content: {
|
||||
type: 'textarea',
|
||||
label: 'Content',
|
||||
},
|
||||
backgroundColor: {
|
||||
type: 'text',
|
||||
label: 'Background Color',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
content: 'Important information goes here.\nAppointment: {{ appointment_datetime }}\nService: {{ service_name }}',
|
||||
backgroundColor: '#f3f4f6',
|
||||
},
|
||||
render: ({ content, backgroundColor }) => {
|
||||
// Convert newlines to <br> for display
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor,
|
||||
borderRadius: '6px',
|
||||
margin: '16px 0',
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: '#374151',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailPanel;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailSpacerProps } from './types';
|
||||
|
||||
const SPACER_SIZES = {
|
||||
sm: '16px',
|
||||
md: '32px',
|
||||
lg: '48px',
|
||||
};
|
||||
|
||||
/**
|
||||
* EmailSpacer - Vertical spacing
|
||||
*
|
||||
* Adds vertical whitespace between components.
|
||||
*/
|
||||
export const EmailSpacer: ComponentConfig<EmailSpacerProps> = {
|
||||
label: 'Email Spacer',
|
||||
fields: {
|
||||
size: {
|
||||
type: 'radio',
|
||||
label: 'Size',
|
||||
options: [
|
||||
{ label: 'Small', value: 'sm' },
|
||||
{ label: 'Medium', value: 'md' },
|
||||
{ label: 'Large', value: 'lg' },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
size: 'md',
|
||||
},
|
||||
render: ({ size }) => {
|
||||
const height = SPACER_SIZES[size] || SPACER_SIZES.md;
|
||||
|
||||
return <div style={{ height }} />;
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailSpacer;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailTextProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailText - Paragraph text content
|
||||
*
|
||||
* Renders text content with email-safe inline styles.
|
||||
* Supports template tags and newline conversion to <br>.
|
||||
*/
|
||||
export const EmailText: ComponentConfig<EmailTextProps> = {
|
||||
label: 'Email Text',
|
||||
fields: {
|
||||
content: {
|
||||
type: 'textarea',
|
||||
label: 'Content',
|
||||
},
|
||||
align: {
|
||||
type: 'radio',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Center', value: 'center' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
content: 'Your email content here. Use {{ customer_name }} for personalization.',
|
||||
align: 'left',
|
||||
},
|
||||
render: ({ content, align }) => {
|
||||
// Convert newlines to <br> for display
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
lineHeight: '1.6',
|
||||
color: '#374151',
|
||||
textAlign: align,
|
||||
margin: 0,
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailText;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailTwoColumnProps } from './types';
|
||||
|
||||
/**
|
||||
* EmailTwoColumn - Two-column layout
|
||||
*
|
||||
* Renders content in two columns side by side.
|
||||
* Note: In actual email rendering, this uses tables for compatibility.
|
||||
*/
|
||||
export const EmailTwoColumn: ComponentConfig<EmailTwoColumnProps> = {
|
||||
label: 'Email Two Column',
|
||||
fields: {
|
||||
leftContent: {
|
||||
type: 'textarea',
|
||||
label: 'Left Column',
|
||||
},
|
||||
rightContent: {
|
||||
type: 'textarea',
|
||||
label: 'Right Column',
|
||||
},
|
||||
gap: {
|
||||
type: 'text',
|
||||
label: 'Gap',
|
||||
},
|
||||
},
|
||||
defaultProps: {
|
||||
leftContent: 'Left column content',
|
||||
rightContent: 'Right column content',
|
||||
gap: '20px',
|
||||
},
|
||||
render: ({ leftContent, rightContent, gap }) => {
|
||||
const renderContent = (content: string) => {
|
||||
const lines = content.split('\n');
|
||||
return lines.map((line, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{line}
|
||||
{i < lines.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap,
|
||||
margin: '16px 0',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, fontSize: '16px', lineHeight: '1.6', color: '#374151' }}>
|
||||
{renderContent(leftContent)}
|
||||
</div>
|
||||
<div style={{ flex: 1, fontSize: '16px', lineHeight: '1.6', color: '#374151' }}>
|
||||
{renderContent(rightContent)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default EmailTwoColumn;
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Email Template Components
|
||||
*
|
||||
* Puck components designed specifically for email templates.
|
||||
* These render email-safe HTML with inline styles and table-based layout.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
|
||||
// Components
|
||||
export { EmailLayout } from './EmailLayout';
|
||||
export { EmailHeader } from './EmailHeader';
|
||||
export { EmailHeading } from './EmailHeading';
|
||||
export { EmailText } from './EmailText';
|
||||
export { EmailButton } from './EmailButton';
|
||||
export { EmailDivider } from './EmailDivider';
|
||||
export { EmailSpacer } from './EmailSpacer';
|
||||
export { EmailImage } from './EmailImage';
|
||||
export { EmailPanel } from './EmailPanel';
|
||||
export { EmailTwoColumn } from './EmailTwoColumn';
|
||||
export { EmailFooter } from './EmailFooter';
|
||||
export { EmailBranding } from './EmailBranding';
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Email Template Component Types
|
||||
*
|
||||
* These components are designed for email-safe output:
|
||||
* - Table-based layout
|
||||
* - Inline styles
|
||||
* - No JavaScript or dynamic content
|
||||
*/
|
||||
|
||||
// Email Layout Props
|
||||
export interface EmailLayoutProps {
|
||||
backgroundColor: string;
|
||||
contentBackgroundColor: string;
|
||||
}
|
||||
|
||||
// Email Header Props
|
||||
export interface EmailHeaderProps {
|
||||
logoUrl?: string;
|
||||
businessName: string;
|
||||
preheader?: string;
|
||||
}
|
||||
|
||||
// Email Heading Props
|
||||
export interface EmailHeadingProps {
|
||||
text: string;
|
||||
level: 'h1' | 'h2' | 'h3';
|
||||
align: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Email Text Props
|
||||
export interface EmailTextProps {
|
||||
content: string;
|
||||
align: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Email Button Props
|
||||
export interface EmailButtonProps {
|
||||
text: string;
|
||||
href: string;
|
||||
variant: 'primary' | 'secondary';
|
||||
align: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Email Divider Props
|
||||
export interface EmailDividerProps {
|
||||
// No props needed - simple horizontal line
|
||||
}
|
||||
|
||||
// Email Spacer Props
|
||||
export interface EmailSpacerProps {
|
||||
size: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
// Email Image Props
|
||||
export interface EmailImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
maxWidth: string;
|
||||
align: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
// Email Panel Props (highlighted box)
|
||||
export interface EmailPanelProps {
|
||||
content: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
// Email Two Column Props
|
||||
export interface EmailTwoColumnProps {
|
||||
leftContent: string;
|
||||
rightContent: string;
|
||||
gap: string;
|
||||
}
|
||||
|
||||
// Email Footer Props
|
||||
export interface EmailFooterProps {
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
// Email Branding Props
|
||||
export interface EmailBrandingProps {
|
||||
showBranding: boolean;
|
||||
}
|
||||
|
||||
// All email component props
|
||||
export type EmailComponentProps = {
|
||||
EmailLayout: EmailLayoutProps;
|
||||
EmailHeader: EmailHeaderProps;
|
||||
EmailHeading: EmailHeadingProps;
|
||||
EmailText: EmailTextProps;
|
||||
EmailButton: EmailButtonProps;
|
||||
EmailDivider: EmailDividerProps;
|
||||
EmailSpacer: EmailSpacerProps;
|
||||
EmailImage: EmailImageProps;
|
||||
EmailPanel: EmailPanelProps;
|
||||
EmailTwoColumn: EmailTwoColumnProps;
|
||||
EmailFooter: EmailFooterProps;
|
||||
EmailBranding: EmailBrandingProps;
|
||||
};
|
||||
|
||||
// Email-specific Puck data structure
|
||||
export interface EmailPuckData {
|
||||
content: Array<{
|
||||
type: keyof EmailComponentProps;
|
||||
props: Partial<EmailComponentProps[keyof EmailComponentProps]> & { id?: string };
|
||||
}>;
|
||||
root: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Puck Configuration for Email Templates
|
||||
*
|
||||
* Email templates use component types like EmailHeader, EmailText, etc.
|
||||
* These must match the types stored in the database.
|
||||
*/
|
||||
import type { Config } from '@measured/puck';
|
||||
|
||||
// Import ALL email-specific components
|
||||
import { EmailHeader } from './components/email/EmailHeader';
|
||||
import { EmailHeading } from './components/email/EmailHeading';
|
||||
import { EmailText } from './components/email/EmailText';
|
||||
import { EmailButton } from './components/email/EmailButton';
|
||||
import { EmailDivider } from './components/email/EmailDivider';
|
||||
import { EmailSpacer } from './components/email/EmailSpacer';
|
||||
import { EmailImage } from './components/email/EmailImage';
|
||||
import { EmailPanel } from './components/email/EmailPanel';
|
||||
import { EmailTwoColumn } from './components/email/EmailTwoColumn';
|
||||
import { EmailFooter } from './components/email/EmailFooter';
|
||||
import { EmailBranding } from './components/email/EmailBranding';
|
||||
|
||||
// Import the combined type
|
||||
import type { EmailComponentProps } from './components/email/types';
|
||||
|
||||
console.log('[emailConfig] Loading ALL email components');
|
||||
console.log('[emailConfig] Verifying render functions are distinct:');
|
||||
console.log(' EmailHeader.render:', EmailHeader.render?.toString().substring(0, 50));
|
||||
console.log(' EmailHeading.render:', EmailHeading.render?.toString().substring(0, 50));
|
||||
console.log(' EmailText.render:', EmailText.render?.toString().substring(0, 50));
|
||||
console.log(' EmailButton.render:', EmailButton.render?.toString().substring(0, 50));
|
||||
console.log(' EmailFooter.render:', EmailFooter.render?.toString().substring(0, 50));
|
||||
console.log(' Are renders same?', EmailHeader.render === EmailFooter.render);
|
||||
|
||||
// Create the email config with ALL components - using direct assignment (not spread)
|
||||
export const emailPuckConfig: Config<EmailComponentProps> = {
|
||||
categories: {
|
||||
structure: {
|
||||
title: 'Structure',
|
||||
components: ['EmailHeader', 'EmailFooter'],
|
||||
},
|
||||
content: {
|
||||
title: 'Content',
|
||||
components: ['EmailHeading', 'EmailText', 'EmailButton', 'EmailImage'],
|
||||
},
|
||||
layout: {
|
||||
title: 'Layout',
|
||||
components: ['EmailSpacer', 'EmailDivider', 'EmailPanel', 'EmailTwoColumn'],
|
||||
},
|
||||
other: {
|
||||
title: 'Other',
|
||||
components: ['EmailBranding'],
|
||||
},
|
||||
},
|
||||
components: {
|
||||
// Direct assignment - no spread to rule out reference issues
|
||||
EmailHeader,
|
||||
EmailFooter,
|
||||
EmailHeading,
|
||||
EmailText,
|
||||
EmailButton,
|
||||
EmailImage,
|
||||
EmailSpacer,
|
||||
EmailDivider,
|
||||
EmailPanel,
|
||||
EmailTwoColumn,
|
||||
EmailBranding,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[emailConfig] Config ready with components:', Object.keys(emailPuckConfig.components));
|
||||
|
||||
/**
|
||||
* Get email editor config - creates a fresh clone each time.
|
||||
*/
|
||||
export function getEmailEditorConfig(): Config<EmailComponentProps> {
|
||||
const clonedConfig: Config<EmailComponentProps> = {
|
||||
...emailPuckConfig,
|
||||
components: { ...emailPuckConfig.components },
|
||||
categories: emailPuckConfig.categories
|
||||
? JSON.parse(JSON.stringify(emailPuckConfig.categories))
|
||||
: undefined,
|
||||
};
|
||||
return clonedConfig;
|
||||
}
|
||||
|
||||
export default emailPuckConfig;
|
||||
+61
-47
@@ -479,53 +479,6 @@ export interface PluginInstallation {
|
||||
scheduledTaskId?: string;
|
||||
}
|
||||
|
||||
// --- Email Template Types ---
|
||||
|
||||
export type EmailTemplateScope = 'BUSINESS' | 'PLATFORM';
|
||||
|
||||
export type EmailTemplateCategory =
|
||||
| 'APPOINTMENT'
|
||||
| 'REMINDER'
|
||||
| 'CONFIRMATION'
|
||||
| 'MARKETING'
|
||||
| 'NOTIFICATION'
|
||||
| 'REPORT'
|
||||
| 'OTHER';
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
textContent: string;
|
||||
scope: EmailTemplateScope;
|
||||
isDefault: boolean;
|
||||
category: EmailTemplateCategory;
|
||||
previewContext?: Record<string, any>;
|
||||
createdBy?: number;
|
||||
createdByName?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreview {
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
textContent: string;
|
||||
forceFooter: boolean;
|
||||
}
|
||||
|
||||
export interface EmailTemplateVariable {
|
||||
code: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplateVariableGroup {
|
||||
category: string;
|
||||
items: EmailTemplateVariable[];
|
||||
}
|
||||
|
||||
// --- Contract Types ---
|
||||
|
||||
export type ContractScope = 'CUSTOMER' | 'APPOINTMENT';
|
||||
@@ -746,4 +699,65 @@ export interface TenantCustomTier {
|
||||
grace_period_started_at: string | null;
|
||||
is_active: boolean;
|
||||
days_until_expiry: number | null;
|
||||
}
|
||||
|
||||
// --- System Email Template Types (Puck-based) ---
|
||||
|
||||
export type SystemEmailType =
|
||||
| 'welcome'
|
||||
| 'appointment_confirmation'
|
||||
| 'appointment_reminder'
|
||||
| 'appointment_rescheduled'
|
||||
| 'appointment_cancelled'
|
||||
| 'thank_you'
|
||||
| 'contract_signing_request'
|
||||
| 'contract_reminder'
|
||||
| 'contract_signed'
|
||||
| 'payment_receipt'
|
||||
| 'invoice'
|
||||
| 'payment_reminder'
|
||||
| 'ticket_assigned'
|
||||
| 'ticket_reply'
|
||||
| 'ticket_resolved';
|
||||
|
||||
export type SystemEmailCategory =
|
||||
| 'welcome'
|
||||
| 'appointment'
|
||||
| 'contract'
|
||||
| 'payment'
|
||||
| 'ticket';
|
||||
|
||||
export interface SystemEmailTemplate {
|
||||
email_type: SystemEmailType;
|
||||
subject_template: string;
|
||||
puck_data: Record<string, any>;
|
||||
is_active: boolean;
|
||||
is_customized: boolean;
|
||||
display_name: string;
|
||||
description: string;
|
||||
category: SystemEmailCategory;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface SystemEmailTemplateDetail extends SystemEmailTemplate {
|
||||
available_tags: SystemEmailTag[];
|
||||
}
|
||||
|
||||
export interface SystemEmailTag {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface SystemEmailTemplatePreview {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SystemEmailTemplateUpdate {
|
||||
subject_template: string;
|
||||
puck_data: Record<string, any>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"9a7e4977473ed55fa848-618b0c2ae07e5089ab92"
|
||||
"6f1a4b04e7ad1ff99f24-d68c404526a42bfecb67",
|
||||
"6f1a4b04e7ad1ff99f24-6b3beabbc695cf50d356"
|
||||
]
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- button "Collapse sidebar" [ref=e6]:
|
||||
- img [ref=e7]
|
||||
- generic [ref=e13]:
|
||||
- heading "Smooth Schedule" [level=1] [ref=e14]
|
||||
- paragraph [ref=e15]: superuser
|
||||
- navigation [ref=e16]:
|
||||
- paragraph [ref=e17]: Operations
|
||||
- link "Dashboard" [ref=e18] [cursor=pointer]:
|
||||
- /url: /platform/dashboard
|
||||
- img [ref=e19]
|
||||
- generic [ref=e24]: Dashboard
|
||||
- link "Businesses" [ref=e25] [cursor=pointer]:
|
||||
- /url: /platform/businesses
|
||||
- img [ref=e26]
|
||||
- generic [ref=e30]: Businesses
|
||||
- link "Users" [ref=e31] [cursor=pointer]:
|
||||
- /url: /platform/users
|
||||
- img [ref=e32]
|
||||
- generic [ref=e37]: Users
|
||||
- link "Support" [active] [ref=e38] [cursor=pointer]:
|
||||
- /url: /platform/support
|
||||
- img [ref=e39]
|
||||
- generic [ref=e41]: Support
|
||||
- paragraph [ref=e42]: System
|
||||
- link "Staff" [ref=e43] [cursor=pointer]:
|
||||
- /url: /platform/staff
|
||||
- img [ref=e44]
|
||||
- generic [ref=e46]: Staff
|
||||
- link "Platform Settings" [ref=e47] [cursor=pointer]:
|
||||
- /url: /platform/settings
|
||||
- img [ref=e48]
|
||||
- generic [ref=e51]: Platform Settings
|
||||
- generic [ref=e52]:
|
||||
- link "Help" [ref=e53] [cursor=pointer]:
|
||||
- /url: /help/ticketing
|
||||
- img [ref=e54]
|
||||
- generic [ref=e57]: Help
|
||||
- link "API Docs" [ref=e58] [cursor=pointer]:
|
||||
- /url: /help/api
|
||||
- img [ref=e59]
|
||||
- generic [ref=e62]: API Docs
|
||||
- generic [ref=e63]:
|
||||
- banner [ref=e64]:
|
||||
- generic [ref=e66]:
|
||||
- img [ref=e67]
|
||||
- generic [ref=e70]: smoothschedule.com
|
||||
- generic [ref=e71]: /
|
||||
- generic [ref=e72]: Admin Console
|
||||
- generic [ref=e73]:
|
||||
- button [ref=e74]:
|
||||
- img [ref=e75]
|
||||
- button "Open notifications" [ref=e78]:
|
||||
- img [ref=e79]
|
||||
- button "Super User Superuser SU" [ref=e83]:
|
||||
- generic [ref=e84]:
|
||||
- paragraph [ref=e85]: Super User
|
||||
- paragraph [ref=e86]: Superuser
|
||||
- generic [ref=e87]: SU
|
||||
- img [ref=e88]
|
||||
- main [ref=e90]:
|
||||
- generic [ref=e91]:
|
||||
- img [ref=e92]
|
||||
- paragraph [ref=e94]: Error loading tickets
|
||||
- generic [ref=e95]: $0k
|
||||
```
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,123 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Email Preview Logo', () => {
|
||||
test('should display SmoothSchedule logo in email preview', async ({ page }) => {
|
||||
// Increase timeout for this test
|
||||
test.setTimeout(90000);
|
||||
|
||||
// Go directly to the business subdomain login
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Login using input types
|
||||
const emailInput = page.locator('input[type="email"]');
|
||||
const passwordInput = page.locator('input[type="password"]');
|
||||
|
||||
await expect(emailInput).toBeVisible({ timeout: 10000 });
|
||||
await emailInput.fill('timm50@hotmail.com');
|
||||
await passwordInput.fill('starry12');
|
||||
|
||||
// Click sign in button
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for navigation after login
|
||||
await page.waitForURL(/pixel8ed\.lvh\.me:5173/, { timeout: 15000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Navigate to email templates
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/dashboard/settings/email-templates');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Click the first template's expand button
|
||||
const templateCards = page.locator('.space-y-4 > div');
|
||||
const firstCard = templateCards.first();
|
||||
const cardButtons = firstCard.locator('button');
|
||||
const buttonCount = await cardButtons.count();
|
||||
console.log(`Found ${buttonCount} buttons in first card`);
|
||||
|
||||
if (buttonCount > 0) {
|
||||
await cardButtons.last().click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// Click the Preview button
|
||||
const previewButton = page.getByRole('button', { name: /preview/i });
|
||||
await expect(previewButton).toBeVisible({ timeout: 5000 });
|
||||
await previewButton.click();
|
||||
|
||||
// Wait for modal to appear
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Find the iframe using frameLocator
|
||||
const iframeLocator = page.locator('iframe[title="Email Preview"]');
|
||||
await expect(iframeLocator).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Wait for iframe content to load
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Use frameLocator to access iframe content
|
||||
const frame = page.frameLocator('iframe[title="Email Preview"]');
|
||||
|
||||
// Look for the branding section with the logo
|
||||
const brandingImg = frame.locator('img[alt="SmoothSchedule"]');
|
||||
|
||||
// Scroll the iframe element itself to see the footer
|
||||
await brandingImg.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check if image exists
|
||||
const imgCount = await brandingImg.count();
|
||||
console.log(`Found ${imgCount} SmoothSchedule logo image(s)`);
|
||||
|
||||
if (imgCount > 0) {
|
||||
// Check if the image has a valid src (data URL)
|
||||
const imgSrc = await brandingImg.first().getAttribute('src');
|
||||
console.log(`Image src starts with: ${imgSrc?.substring(0, 50)}`);
|
||||
|
||||
// Verify it's a data URL
|
||||
expect(imgSrc).toContain('data:image/png;base64');
|
||||
|
||||
// Check all image attributes
|
||||
const width = await brandingImg.first().getAttribute('width');
|
||||
const height = await brandingImg.first().getAttribute('height');
|
||||
const style = await brandingImg.first().getAttribute('style');
|
||||
console.log(`Image attributes - width: ${width}, height: ${height}, style: ${style}`);
|
||||
|
||||
// Check if image has natural dimensions (meaning it loaded)
|
||||
const naturalWidth = await brandingImg.first().evaluate((img: HTMLImageElement) => img.naturalWidth);
|
||||
const naturalHeight = await brandingImg.first().evaluate((img: HTMLImageElement) => img.naturalHeight);
|
||||
console.log(`Image natural dimensions: ${naturalWidth}x${naturalHeight}`);
|
||||
|
||||
// Check computed/displayed dimensions
|
||||
const boundingBox = await brandingImg.first().boundingBox();
|
||||
console.log(`Image bounding box: ${JSON.stringify(boundingBox)}`);
|
||||
|
||||
// Check if parent element is visible
|
||||
const parentHtml = await brandingImg.first().evaluate((img) => img.parentElement?.outerHTML);
|
||||
console.log(`Parent element: ${parentHtml?.substring(0, 300)}`);
|
||||
|
||||
expect(naturalWidth).toBeGreaterThan(0);
|
||||
expect(naturalHeight).toBeGreaterThan(0);
|
||||
|
||||
// Check if image is actually visible (has non-zero display dimensions)
|
||||
if (boundingBox) {
|
||||
console.log(`Image displayed at ${boundingBox.width}x${boundingBox.height} pixels`);
|
||||
expect(boundingBox.width).toBeGreaterThan(0);
|
||||
expect(boundingBox.height).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Take screenshot focused on the branding area
|
||||
await page.screenshot({ path: 'test-results/email-preview-footer.png', fullPage: true });
|
||||
|
||||
console.log('SUCCESS: Logo image loaded correctly');
|
||||
} else {
|
||||
// Debug: get the HTML
|
||||
const html = await frame.locator('body').innerHTML();
|
||||
console.log('Iframe body HTML:', html);
|
||||
|
||||
throw new Error('SmoothSchedule logo image not found in iframe');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Email Template Editor', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login with real credentials on the pixel8ed subdomain
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/login');
|
||||
|
||||
// Wait for the login form
|
||||
await expect(page.locator('input[type="email"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Fill in the login form
|
||||
await page.fill('input[type="email"]', 'timm50@hotmail.com');
|
||||
await page.fill('input[type="password"]', 'starry12');
|
||||
|
||||
// Click sign in button
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for login to complete and redirect to dashboard
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20000 });
|
||||
});
|
||||
|
||||
test('should check site builder works correctly', async ({ page }) => {
|
||||
// Navigate directly to site builder
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/dashboard/site-editor', { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'test-results/site-builder-state.png', fullPage: true });
|
||||
|
||||
// Check iframe content for multiple component types
|
||||
const iframe = page.frameLocator('iframe').first();
|
||||
try {
|
||||
const iframeContent = await iframe.locator('body').textContent({ timeout: 3000 });
|
||||
console.log('Site builder iframe content (first 500 chars):', iframeContent?.substring(0, 500));
|
||||
|
||||
// Site builder should have diverse content
|
||||
expect(iframeContent?.length).toBeGreaterThan(100);
|
||||
} catch (e) {
|
||||
console.log('No iframe content found');
|
||||
}
|
||||
});
|
||||
|
||||
test('should check email template editor with actual API data', async ({ page }) => {
|
||||
// Navigate directly to the dedicated email template editor page
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/dashboard/email-template-editor/welcome', { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for editor to load - need to wait for Puck to render
|
||||
await expect(page.getByText(/Email Subject/i)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for Puck editor to appear (Components heading)
|
||||
await expect(page.getByRole('heading', { name: 'Components' })).toBeVisible({ timeout: 15000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'test-results/email-editor-api-data.png', fullPage: true });
|
||||
|
||||
// Count component types shown
|
||||
const emailHeaderCount = await page.locator('text="Email Header"').count();
|
||||
const emailHeadingCount = await page.locator('text="Email Heading"').count();
|
||||
const emailTextCount = await page.locator('text="Email Text"').count();
|
||||
const emailButtonCount = await page.locator('text="Email Button"').count();
|
||||
const emailSpacerCount = await page.locator('text="Email Spacer"').count();
|
||||
const emailFooterCount = await page.locator('text="Email Footer"').count();
|
||||
|
||||
console.log('Component type counts in editor:');
|
||||
console.log(' Email Header:', emailHeaderCount);
|
||||
console.log(' Email Heading:', emailHeadingCount);
|
||||
console.log(' Email Text:', emailTextCount);
|
||||
console.log(' Email Button:', emailButtonCount);
|
||||
console.log(' Email Spacer:', emailSpacerCount);
|
||||
console.log(' Email Footer:', emailFooterCount);
|
||||
|
||||
// Check iframe content for diverse component rendering
|
||||
const iframe = page.frameLocator('iframe').first();
|
||||
try {
|
||||
const iframeContent = await iframe.locator('body').textContent({ timeout: 3000 });
|
||||
console.log('Iframe content (first 800 chars):', iframeContent?.substring(0, 800));
|
||||
|
||||
// Content should show diverse email template components
|
||||
expect(iframeContent?.length).toBeGreaterThan(100);
|
||||
} catch (e) {
|
||||
console.log('No iframe content found');
|
||||
}
|
||||
|
||||
// With correct config, we should see multiple different component types
|
||||
// If the bug is fixed, no single component type should dominate
|
||||
const maxCount = Math.max(emailHeaderCount, emailHeadingCount, emailTextCount,
|
||||
emailButtonCount, emailSpacerCount, emailFooterCount);
|
||||
console.log('Max single component type count:', maxCount);
|
||||
|
||||
// A working editor should have reasonable distribution (max ~4-5 per type)
|
||||
expect(maxCount).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Site builder renders different components', async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/login');
|
||||
await page.getByPlaceholder(/username/i).fill('pixel8ed');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Navigate to site builder
|
||||
await page.waitForTimeout(2000);
|
||||
await page.goto('http://pixel8ed.lvh.me:5173/dashboard/site-editor');
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'test-results/site-builder-state.png', fullPage: true });
|
||||
|
||||
// Check for component variety in the iframe
|
||||
const iframe = page.frameLocator('iframe');
|
||||
const iframeContent = await iframe.locator('body').textContent().catch(() => '');
|
||||
|
||||
console.log('=== SITE BUILDER TEST ===');
|
||||
console.log('Iframe content (first 500 chars):', iframeContent?.substring(0, 500));
|
||||
|
||||
// Check the Outline section for component names
|
||||
const outlineSection = page.locator('h2:has-text("Outline")').locator('..').locator('..');
|
||||
const outlineText = await outlineSection.textContent().catch(() => 'Not found');
|
||||
console.log('Outline section:', outlineText?.substring(0, 500));
|
||||
});
|
||||
@@ -1,567 +0,0 @@
|
||||
# Email Template Generator Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Create an email template system that allows both platform admins and business users to create reusable email templates. Templates can be attached to plugins via a new template variable type that prompts users to select from their email templates.
|
||||
|
||||
## Requirements Summary
|
||||
|
||||
1. **Dual access**: Available in both platform admin and business areas
|
||||
2. **Both formats**: Support text and HTML email templates
|
||||
3. **Visual preview**: Show how the final email will look
|
||||
4. **Plugin integration**: Templates attachable via a new template tag type
|
||||
5. **Separate templates**: Platform and business templates are completely separate
|
||||
6. **Footer enforcement**: Free tier businesses must show "Powered by Smooth Schedule" footer (non-overridable)
|
||||
7. **Editor modes**: Both visual builder AND code editor views
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Backend - EmailTemplate Model
|
||||
|
||||
### 1.1 Create EmailTemplate Model
|
||||
|
||||
**Location**: `schedule/models.py`
|
||||
|
||||
```python
|
||||
class EmailTemplate(models.Model):
|
||||
"""
|
||||
Reusable email template for plugins and automations.
|
||||
|
||||
Supports both text and HTML content with template variable substitution.
|
||||
"""
|
||||
|
||||
class Scope(models.TextChoices):
|
||||
BUSINESS = 'BUSINESS', 'Business' # Tenant-specific
|
||||
PLATFORM = 'PLATFORM', 'Platform' # Platform-wide (shared)
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Email structure
|
||||
subject = models.CharField(max_length=500, help_text="Email subject line - supports template variables")
|
||||
html_content = models.TextField(blank=True, help_text="HTML email body")
|
||||
text_content = models.TextField(blank=True, help_text="Plain text email body")
|
||||
|
||||
# Scope
|
||||
scope = models.CharField(
|
||||
max_length=20,
|
||||
choices=Scope.choices,
|
||||
default=Scope.BUSINESS,
|
||||
)
|
||||
|
||||
# Only for PLATFORM scope templates
|
||||
is_default = models.BooleanField(default=False, help_text="Default template for certain triggers")
|
||||
|
||||
# Metadata
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='created_email_templates'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Category for organization
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=[
|
||||
('APPOINTMENT', 'Appointment'),
|
||||
('REMINDER', 'Reminder'),
|
||||
('CONFIRMATION', 'Confirmation'),
|
||||
('MARKETING', 'Marketing'),
|
||||
('NOTIFICATION', 'Notification'),
|
||||
('REPORT', 'Report'),
|
||||
('OTHER', 'Other'),
|
||||
],
|
||||
default='OTHER'
|
||||
)
|
||||
|
||||
# Preview data for visual preview
|
||||
preview_context = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Sample data for rendering preview"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
indexes = [
|
||||
models.Index(fields=['scope', 'category']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_scope_display()})"
|
||||
|
||||
def render(self, context: dict, force_footer: bool = False) -> tuple[str, str, str]:
|
||||
"""
|
||||
Render the template with given context.
|
||||
|
||||
Args:
|
||||
context: Dictionary of template variables
|
||||
force_footer: If True, append "Powered by Smooth Schedule" footer
|
||||
|
||||
Returns:
|
||||
Tuple of (subject, html_content, text_content)
|
||||
"""
|
||||
from .template_parser import TemplateVariableParser
|
||||
|
||||
subject = TemplateVariableParser.replace_insertion_codes(
|
||||
self.subject, context
|
||||
)
|
||||
html = TemplateVariableParser.replace_insertion_codes(
|
||||
self.html_content, context
|
||||
) if self.html_content else ''
|
||||
text = TemplateVariableParser.replace_insertion_codes(
|
||||
self.text_content, context
|
||||
) if self.text_content else ''
|
||||
|
||||
# Append footer for free tier if applicable
|
||||
if force_footer:
|
||||
html = self._append_html_footer(html)
|
||||
text = self._append_text_footer(text)
|
||||
|
||||
return subject, html, text
|
||||
|
||||
def _append_html_footer(self, html: str) -> str:
|
||||
"""Append Powered by Smooth Schedule footer to HTML"""
|
||||
footer = '''
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
|
||||
<p>
|
||||
Powered by
|
||||
<a href="https://smoothschedule.com" style="color: #6366f1; text-decoration: none; font-weight: 500;">
|
||||
SmoothSchedule
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
'''
|
||||
# Insert before </body> if present, otherwise append
|
||||
if '</body>' in html.lower():
|
||||
import re
|
||||
return re.sub(r'(</body>)', footer + r'\1', html, flags=re.IGNORECASE)
|
||||
return html + footer
|
||||
|
||||
def _append_text_footer(self, text: str) -> str:
|
||||
"""Append Powered by Smooth Schedule footer to plain text"""
|
||||
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
|
||||
return text + footer
|
||||
```
|
||||
|
||||
### 1.2 Update TemplateVariableParser
|
||||
|
||||
**Location**: `schedule/template_parser.py`
|
||||
|
||||
Add new variable type `email_template`:
|
||||
|
||||
```python
|
||||
# Add to VARIABLE_PATTERN handling
|
||||
# Format: {{PROMPT:variable_name|description||email_template}}
|
||||
|
||||
# When type == 'email_template', the frontend will:
|
||||
# 1. Fetch available email templates from /api/email-templates/
|
||||
# 2. Show a dropdown selector
|
||||
# 3. Store the selected template ID in config_values
|
||||
```
|
||||
|
||||
### 1.3 Create Serializers
|
||||
|
||||
**Location**: `schedule/serializers.py`
|
||||
|
||||
```python
|
||||
class EmailTemplateSerializer(serializers.ModelSerializer):
|
||||
"""Full serializer for CRUD operations"""
|
||||
created_by_name = serializers.CharField(source='created_by.full_name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = EmailTemplate
|
||||
fields = [
|
||||
'id', 'name', 'description', 'subject',
|
||||
'html_content', 'text_content', 'scope',
|
||||
'is_default', 'category', 'preview_context',
|
||||
'created_by', 'created_by_name',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'created_by']
|
||||
|
||||
|
||||
class EmailTemplateListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for dropdowns"""
|
||||
|
||||
class Meta:
|
||||
model = EmailTemplate
|
||||
fields = ['id', 'name', 'description', 'category', 'scope']
|
||||
|
||||
|
||||
class EmailTemplatePreviewSerializer(serializers.Serializer):
|
||||
"""Serializer for preview endpoint"""
|
||||
subject = serializers.CharField()
|
||||
html_content = serializers.CharField(allow_blank=True)
|
||||
text_content = serializers.CharField(allow_blank=True)
|
||||
context = serializers.DictField(required=False, default=dict)
|
||||
```
|
||||
|
||||
### 1.4 Create ViewSet
|
||||
|
||||
**Location**: `schedule/views.py`
|
||||
|
||||
```python
|
||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing email templates.
|
||||
|
||||
- Business users see only BUSINESS scope templates
|
||||
- Platform users can also see/create PLATFORM scope templates
|
||||
"""
|
||||
serializer_class = EmailTemplateSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
# Platform users see all templates
|
||||
if user.is_platform_user:
|
||||
scope = self.request.query_params.get('scope')
|
||||
if scope:
|
||||
return EmailTemplate.objects.filter(scope=scope.upper())
|
||||
return EmailTemplate.objects.all()
|
||||
|
||||
# Business users only see BUSINESS scope templates
|
||||
return EmailTemplate.objects.filter(scope=EmailTemplate.Scope.BUSINESS)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def preview(self, request):
|
||||
"""Render a preview of the template with sample data"""
|
||||
serializer = EmailTemplatePreviewSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
from .template_parser import TemplateVariableParser
|
||||
|
||||
context = serializer.validated_data.get('context', {})
|
||||
subject = serializer.validated_data['subject']
|
||||
html = serializer.validated_data.get('html_content', '')
|
||||
text = serializer.validated_data.get('text_content', '')
|
||||
|
||||
# Add default sample values
|
||||
default_context = {
|
||||
'BUSINESS_NAME': 'Demo Business',
|
||||
'BUSINESS_EMAIL': 'contact@demo.com',
|
||||
'BUSINESS_PHONE': '(555) 123-4567',
|
||||
'CUSTOMER_NAME': 'John Doe',
|
||||
'CUSTOMER_EMAIL': 'john@example.com',
|
||||
'APPOINTMENT_TIME': 'Monday, January 15, 2025 at 2:00 PM',
|
||||
'APPOINTMENT_DATE': 'January 15, 2025',
|
||||
'APPOINTMENT_SERVICE': 'Consultation',
|
||||
'TODAY': datetime.now().strftime('%B %d, %Y'),
|
||||
'NOW': datetime.now().strftime('%B %d, %Y at %I:%M %p'),
|
||||
}
|
||||
default_context.update(context)
|
||||
|
||||
rendered_subject = TemplateVariableParser.replace_insertion_codes(subject, default_context)
|
||||
rendered_html = TemplateVariableParser.replace_insertion_codes(html, default_context)
|
||||
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context)
|
||||
|
||||
# Check if free tier - append footer
|
||||
force_footer = False
|
||||
if not request.user.is_platform_user:
|
||||
from django.db import connection
|
||||
if hasattr(connection, 'tenant') and connection.tenant.subscription_tier == 'FREE':
|
||||
force_footer = True
|
||||
|
||||
if force_footer:
|
||||
rendered_html = EmailTemplate._append_html_footer(None, rendered_html)
|
||||
rendered_text = EmailTemplate._append_text_footer(None, rendered_text)
|
||||
|
||||
return Response({
|
||||
'subject': rendered_subject,
|
||||
'html_content': rendered_html,
|
||||
'text_content': rendered_text,
|
||||
'force_footer': force_footer,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""Create a copy of an existing template"""
|
||||
template = self.get_object()
|
||||
new_template = EmailTemplate.objects.create(
|
||||
name=f"{template.name} (Copy)",
|
||||
description=template.description,
|
||||
subject=template.subject,
|
||||
html_content=template.html_content,
|
||||
text_content=template.text_content,
|
||||
scope=template.scope,
|
||||
category=template.category,
|
||||
preview_context=template.preview_context,
|
||||
created_by=request.user,
|
||||
)
|
||||
return Response(EmailTemplateSerializer(new_template).data, status=201)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Plugin Integration
|
||||
|
||||
### 2.1 Update Template Parser for email_template Type
|
||||
|
||||
**Location**: `schedule/template_parser.py`
|
||||
|
||||
```python
|
||||
# In extract_variables method, when type == 'email_template':
|
||||
# Return special metadata to indicate dropdown
|
||||
|
||||
@classmethod
|
||||
def _infer_type(cls, var_name: str, description: str) -> str:
|
||||
# ... existing logic ...
|
||||
|
||||
# Check for explicit email_template type
|
||||
# This is handled in the main extraction logic
|
||||
pass
|
||||
```
|
||||
|
||||
### 2.2 Update Plugin Execution to Handle Email Templates
|
||||
|
||||
**Location**: `schedule/tasks.py`
|
||||
|
||||
```python
|
||||
def execute_plugin_with_email(plugin_code: str, config_values: dict, context: dict):
|
||||
"""
|
||||
Execute plugin code with email template support.
|
||||
|
||||
When config_values contains an email_template_id, load and render it.
|
||||
"""
|
||||
# Check for email template references in config
|
||||
for key, value in config_values.items():
|
||||
if key.endswith('_email_template') and isinstance(value, int):
|
||||
# Load the email template
|
||||
try:
|
||||
template = EmailTemplate.objects.get(id=value)
|
||||
# Render and make available in context
|
||||
subject, html, text = template.render(context)
|
||||
context[f'{key}_subject'] = subject
|
||||
context[f'{key}_html'] = html
|
||||
context[f'{key}_text'] = text
|
||||
except EmailTemplate.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Continue with normal execution
|
||||
# ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Frontend - Email Template Editor
|
||||
|
||||
### 3.1 Create EmailTemplateEditor Component
|
||||
|
||||
**Location**: `frontend/src/pages/EmailTemplates.tsx`
|
||||
|
||||
Main page with:
|
||||
- List of templates with search/filter
|
||||
- Create/Edit modal with dual-mode editor
|
||||
- Preview panel
|
||||
|
||||
### 3.2 Create EmailTemplateForm Component
|
||||
|
||||
**Location**: `frontend/src/components/EmailTemplateForm.tsx`
|
||||
|
||||
Features:
|
||||
1. **Subject line editor** - Simple text input with variable insertion
|
||||
2. **Content editor** - Tabbed interface:
|
||||
- **Visual mode**: WYSIWYG editor (TipTap or similar)
|
||||
- **Code mode**: Monaco/CodeMirror for raw HTML
|
||||
3. **Plain text editor** - Textarea with variable insertion buttons
|
||||
4. **Preview panel** - Live rendering of HTML email
|
||||
|
||||
### 3.3 Variable Insertion Toolbar
|
||||
|
||||
Available variables shown as clickable chips:
|
||||
- `{{BUSINESS_NAME}}`
|
||||
- `{{BUSINESS_EMAIL}}`
|
||||
- `{{CUSTOMER_NAME}}`
|
||||
- `{{APPOINTMENT_TIME}}`
|
||||
- etc.
|
||||
|
||||
### 3.4 Preview Component
|
||||
|
||||
**Location**: `frontend/src/components/EmailPreview.tsx`
|
||||
|
||||
- Desktop/mobile toggle
|
||||
- Light/dark mode preview
|
||||
- Sample data editor
|
||||
- Footer preview (shown for free tier)
|
||||
|
||||
### 3.5 Update Plugin Config Form
|
||||
|
||||
**Location**: `frontend/src/pages/MyPlugins.tsx`
|
||||
|
||||
When `variable.type === 'email_template'`:
|
||||
```tsx
|
||||
<EmailTemplateSelector
|
||||
value={configValues[key]}
|
||||
onChange={(templateId) => setConfigValues({ ...configValues, [key]: templateId })}
|
||||
scope="BUSINESS" // or "PLATFORM" for platform admin
|
||||
/>
|
||||
```
|
||||
|
||||
### 3.6 EmailTemplateSelector Component
|
||||
|
||||
**Location**: `frontend/src/components/EmailTemplateSelector.tsx`
|
||||
|
||||
- Dropdown showing available templates
|
||||
- Category filtering
|
||||
- Quick preview on hover
|
||||
- "Create New" link
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Footer Enforcement
|
||||
|
||||
### 4.1 Backend Enforcement
|
||||
|
||||
In `EmailTemplate.render()` and preview endpoint:
|
||||
- Check tenant subscription tier
|
||||
- If `FREE`, always append footer regardless of template content
|
||||
- Footer cannot be removed via template editing
|
||||
|
||||
### 4.2 Frontend Enforcement
|
||||
|
||||
In EmailTemplateForm:
|
||||
- Show permanent footer preview for free tier
|
||||
- Disable footer editing for free tier
|
||||
- Show upgrade prompt: "Upgrade to remove footer"
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Platform Admin Features
|
||||
|
||||
### 5.1 Platform Email Templates Page
|
||||
|
||||
**Location**: `frontend/src/pages/platform/PlatformEmailTemplates.tsx`
|
||||
|
||||
- Create/manage PLATFORM scope templates
|
||||
- Set default templates for system events
|
||||
- Preview with tenant context
|
||||
|
||||
### 5.2 Default Templates
|
||||
|
||||
Create seed data for common templates:
|
||||
- Tenant invitation email (already exists)
|
||||
- Appointment reminder
|
||||
- Appointment confirmation
|
||||
- Password reset
|
||||
- Welcome email
|
||||
|
||||
---
|
||||
|
||||
## Database Migrations
|
||||
|
||||
```python
|
||||
# schedule/migrations/XXXX_email_template.py
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('schedule', 'previous_migration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EmailTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('subject', models.CharField(max_length=500)),
|
||||
('html_content', models.TextField(blank=True)),
|
||||
('text_content', models.TextField(blank=True)),
|
||||
('scope', models.CharField(max_length=20, choices=[
|
||||
('BUSINESS', 'Business'),
|
||||
('PLATFORM', 'Platform'),
|
||||
], default='BUSINESS')),
|
||||
('is_default', models.BooleanField(default=False)),
|
||||
('category', models.CharField(max_length=50, choices=[
|
||||
('APPOINTMENT', 'Appointment'),
|
||||
('REMINDER', 'Reminder'),
|
||||
('CONFIRMATION', 'Confirmation'),
|
||||
('MARKETING', 'Marketing'),
|
||||
('NOTIFICATION', 'Notification'),
|
||||
('REPORT', 'Report'),
|
||||
('OTHER', 'Other'),
|
||||
], default='OTHER')),
|
||||
('preview_context', models.JSONField(default=dict, blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='created_email_templates',
|
||||
to='users.user'
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='emailtemplate',
|
||||
index=models.Index(fields=['scope', 'category'], name='schedule_em_scope_123456_idx'),
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/email-templates/` | List templates (filtered by scope) |
|
||||
| POST | `/api/email-templates/` | Create template |
|
||||
| GET | `/api/email-templates/{id}/` | Get template details |
|
||||
| PATCH | `/api/email-templates/{id}/` | Update template |
|
||||
| DELETE | `/api/email-templates/{id}/` | Delete template |
|
||||
| POST | `/api/email-templates/preview/` | Render preview |
|
||||
| POST | `/api/email-templates/{id}/duplicate/` | Duplicate template |
|
||||
|
||||
---
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
| Route | Component | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `/email-templates` | EmailTemplates | Business email templates |
|
||||
| `/email-templates/create` | EmailTemplateForm | Create new template |
|
||||
| `/email-templates/:id/edit` | EmailTemplateForm | Edit existing template |
|
||||
| `/platform/email-templates` | PlatformEmailTemplates | Platform templates |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Backend Model & Migration** - EmailTemplate model
|
||||
2. **Backend API** - Serializers, ViewSet, URLs
|
||||
3. **Frontend List Page** - Basic CRUD
|
||||
4. **Frontend Editor** - Dual-mode editor
|
||||
5. **Preview Component** - Live preview
|
||||
6. **Plugin Integration** - email_template variable type
|
||||
7. **Footer Enforcement** - Free tier logic
|
||||
8. **Platform Admin** - Platform templates page
|
||||
9. **Seed Data** - Default templates
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Create business email template
|
||||
- [ ] Create platform email template (as superuser)
|
||||
- [ ] Preview template with sample data
|
||||
- [ ] Verify footer appears for free tier
|
||||
- [ ] Verify footer hidden for paid tiers
|
||||
- [ ] Verify footer appears in preview for free tier
|
||||
- [ ] Edit template in visual mode
|
||||
- [ ] Edit template in code mode
|
||||
- [ ] Use template in plugin configuration
|
||||
- [ ] Send actual email using template
|
||||
- [ ] Duplicate template
|
||||
- [ ] Delete template
|
||||
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
Ticket Email Notification Service
|
||||
|
||||
Sends email notifications for ticket events using customizable email templates.
|
||||
Sends email notifications for ticket events using Puck-based email templates.
|
||||
Handles:
|
||||
- Ticket assignment notifications
|
||||
- Status change notifications
|
||||
- Reply notifications (to both staff and customers)
|
||||
- Resolution notifications
|
||||
|
||||
Uses email templates from the EmailTemplate model with ticket-specific context variables.
|
||||
Uses PuckEmailTemplate with the messaging.email_service for rendering and sending.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -21,6 +21,8 @@ from django.core.mail import EmailMultiAlternatives
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Ticket, TicketComment
|
||||
from smoothschedule.communication.messaging.email_types import EmailType
|
||||
from smoothschedule.communication.messaging.email_service import send_system_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,16 +48,16 @@ class TicketEmailService:
|
||||
"""
|
||||
Service for sending ticket-related email notifications.
|
||||
|
||||
Uses EmailTemplate model for customizable templates.
|
||||
Falls back to default templates if none configured.
|
||||
Uses PuckEmailTemplate system for customizable templates.
|
||||
Maps ticket events to EmailType enum values.
|
||||
"""
|
||||
|
||||
# Default template names (should match seed_email_templates.py)
|
||||
TEMPLATE_TICKET_ASSIGNED = 'Ticket Assigned'
|
||||
TEMPLATE_STATUS_CHANGED = 'Ticket Status Changed'
|
||||
TEMPLATE_REPLY_STAFF = 'Ticket Reply - Staff Notification'
|
||||
TEMPLATE_REPLY_CUSTOMER = 'Ticket Reply - Customer Notification'
|
||||
TEMPLATE_RESOLVED = 'Ticket Resolved'
|
||||
# Map ticket events to EmailType enum values
|
||||
EMAIL_TYPE_MAPPING = {
|
||||
'assigned': EmailType.TICKET_ASSIGNED,
|
||||
'reply': EmailType.TICKET_REPLY,
|
||||
'resolved': EmailType.TICKET_RESOLVED,
|
||||
}
|
||||
|
||||
def __init__(self, ticket: Ticket):
|
||||
"""
|
||||
@@ -67,29 +69,13 @@ class TicketEmailService:
|
||||
self.ticket = ticket
|
||||
self.tenant = ticket.tenant
|
||||
|
||||
def _get_email_template(self, template_name: str):
|
||||
"""
|
||||
Get an email template by name.
|
||||
|
||||
Looks up templates in the schedule app's EmailTemplate model.
|
||||
Returns None if template not found.
|
||||
"""
|
||||
try:
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
return EmailTemplate.objects.filter(
|
||||
name=template_name,
|
||||
scope=EmailTemplate.Scope.BUSINESS
|
||||
).first()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load email template '{template_name}': {e}")
|
||||
return None
|
||||
|
||||
def _get_base_context(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get base context variables for all ticket emails.
|
||||
|
||||
Returns:
|
||||
Dictionary of context variables for template rendering
|
||||
Dictionary of context variables for template rendering.
|
||||
Uses snake_case names to match PuckEmailTemplate tag system.
|
||||
"""
|
||||
# Build ticket URL
|
||||
base_url = getattr(settings, 'FRONTEND_URL', 'http://localhost:5173')
|
||||
@@ -99,55 +85,37 @@ class TicketEmailService:
|
||||
ticket_url = f"{base_url}/platform/tickets/{self.ticket.id}"
|
||||
|
||||
# Get business context if tenant exists
|
||||
business_name = self.tenant.name if self.tenant else 'SmoothSchedule Platform'
|
||||
business_email = getattr(self.tenant, 'contact_email', '') if self.tenant else settings.DEFAULT_FROM_EMAIL
|
||||
business_phone = getattr(self.tenant, 'phone', '') if self.tenant else ''
|
||||
tenant_name = self.tenant.name if self.tenant else 'SmoothSchedule Platform'
|
||||
tenant_email = getattr(self.tenant, 'contact_email', '') if self.tenant else settings.DEFAULT_FROM_EMAIL
|
||||
tenant_phone = getattr(self.tenant, 'phone', '') if self.tenant else ''
|
||||
|
||||
# Get creator/customer info
|
||||
creator = self.ticket.creator
|
||||
customer_first_name = creator.first_name if creator else 'Customer'
|
||||
customer_name = creator.get_full_name() if creator else 'Customer'
|
||||
customer_email = creator.email if creator else ''
|
||||
|
||||
return {
|
||||
# Business context
|
||||
'BUSINESS_NAME': business_name,
|
||||
'BUSINESS_EMAIL': business_email,
|
||||
'BUSINESS_PHONE': business_phone,
|
||||
# Tenant/business context
|
||||
'tenant_name': tenant_name,
|
||||
'tenant_email': tenant_email,
|
||||
'tenant_phone': tenant_phone,
|
||||
# Customer context
|
||||
'CUSTOMER_NAME': customer_name,
|
||||
'CUSTOMER_EMAIL': customer_email,
|
||||
'customer_first_name': customer_first_name,
|
||||
'customer_name': customer_name,
|
||||
'customer_email': customer_email,
|
||||
# Ticket context
|
||||
'TICKET_ID': str(self.ticket.id),
|
||||
'TICKET_SUBJECT': self.ticket.subject,
|
||||
'TICKET_MESSAGE': self.ticket.description,
|
||||
'TICKET_STATUS': self.ticket.get_status_display(),
|
||||
'TICKET_PRIORITY': self.ticket.get_priority_display(),
|
||||
'TICKET_CUSTOMER_NAME': customer_name,
|
||||
'TICKET_URL': ticket_url,
|
||||
'ticket_id': str(self.ticket.id),
|
||||
'ticket_subject': self.ticket.subject,
|
||||
'ticket_message': self.ticket.description,
|
||||
'ticket_status': self.ticket.get_status_display(),
|
||||
'ticket_priority': self.ticket.get_priority_display(),
|
||||
'ticket_link': ticket_url,
|
||||
# Date/time
|
||||
'TODAY': timezone.now().strftime('%B %d, %Y'),
|
||||
'NOW': timezone.now().strftime('%B %d, %Y at %I:%M %p'),
|
||||
'current_date': timezone.now().strftime('%B %d, %Y'),
|
||||
'current_time': timezone.now().strftime('%I:%M %p'),
|
||||
}
|
||||
|
||||
def _render_template_variables(self, text: str, context: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Replace {{VARIABLE}} placeholders with actual values.
|
||||
|
||||
Args:
|
||||
text: Template text with {{VARIABLE}} placeholders
|
||||
context: Dictionary of variable values
|
||||
|
||||
Returns:
|
||||
Text with variables replaced
|
||||
"""
|
||||
import re
|
||||
|
||||
def replace_var(match):
|
||||
var_name = match.group(1)
|
||||
return str(context.get(var_name, match.group(0)))
|
||||
|
||||
return re.sub(r'\{\{(\w+)\}\}', replace_var, text)
|
||||
|
||||
def _send_email(
|
||||
self,
|
||||
to_email: str,
|
||||
@@ -299,7 +267,7 @@ class TicketEmailService:
|
||||
"""
|
||||
Send notification when ticket is assigned to someone.
|
||||
|
||||
Sends email to the assignee with ticket details.
|
||||
Sends email to the assignee with ticket details using PuckEmailTemplate.
|
||||
|
||||
Returns:
|
||||
True if email sent successfully
|
||||
@@ -314,33 +282,27 @@ class TicketEmailService:
|
||||
return False
|
||||
|
||||
context = self._get_base_context()
|
||||
context['ASSIGNEE_NAME'] = assignee.get_full_name() or assignee.email
|
||||
context['RECIPIENT_NAME'] = context['ASSIGNEE_NAME']
|
||||
context['assignee_name'] = assignee.get_full_name() or assignee.email
|
||||
context['recipient_name'] = context['assignee_name']
|
||||
|
||||
# Try to get custom template
|
||||
template = self._get_email_template(self.TEMPLATE_TICKET_ASSIGNED)
|
||||
|
||||
if template:
|
||||
subject = self._render_template_variables(template.subject, context)
|
||||
html_content = self._render_template_variables(template.html_content, context)
|
||||
text_content = self._render_template_variables(template.text_content, context)
|
||||
else:
|
||||
# Fallback to default
|
||||
subject = f"[Ticket #{self.ticket.id}] You have been assigned: {self.ticket.subject}"
|
||||
text_content = self._get_default_assignment_text(context)
|
||||
html_content = ''
|
||||
|
||||
return self._send_email(
|
||||
return send_system_email(
|
||||
email_type=EmailType.TICKET_ASSIGNED,
|
||||
to_email=assignee.email,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content
|
||||
context=context,
|
||||
extra_headers={
|
||||
'X-Ticket-ID': str(self.ticket.id),
|
||||
'X-Ticket-Type': self.ticket.ticket_type,
|
||||
},
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
def send_status_change_notification(self, old_status: str, notify_customer: bool = True) -> bool:
|
||||
"""
|
||||
Send notification when ticket status changes.
|
||||
|
||||
Note: Status change emails are not a separate email type in the new system.
|
||||
We use the ticket_reply type as a general notification.
|
||||
|
||||
Args:
|
||||
old_status: Previous status value
|
||||
notify_customer: Whether to notify the ticket creator
|
||||
@@ -352,25 +314,19 @@ class TicketEmailService:
|
||||
return False
|
||||
|
||||
context = self._get_base_context()
|
||||
context['RECIPIENT_NAME'] = self.ticket.creator.get_full_name() or 'Customer'
|
||||
context['OLD_STATUS'] = dict(Ticket.Status.choices).get(old_status, old_status)
|
||||
context['recipient_name'] = self.ticket.creator.get_full_name() or 'Customer'
|
||||
context['old_status'] = dict(Ticket.Status.choices).get(old_status, old_status)
|
||||
context['reply_message'] = f"Your ticket status has been updated from {context['old_status']} to {context['ticket_status']}."
|
||||
|
||||
template = self._get_email_template(self.TEMPLATE_STATUS_CHANGED)
|
||||
|
||||
if template:
|
||||
subject = self._render_template_variables(template.subject, context)
|
||||
html_content = self._render_template_variables(template.html_content, context)
|
||||
text_content = self._render_template_variables(template.text_content, context)
|
||||
else:
|
||||
subject = f"[Ticket #{self.ticket.id}] Status updated: {self.ticket.get_status_display()}"
|
||||
text_content = self._get_default_status_change_text(context)
|
||||
html_content = ''
|
||||
|
||||
return self._send_email(
|
||||
return send_system_email(
|
||||
email_type=EmailType.TICKET_REPLY,
|
||||
to_email=self.ticket.creator.email,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content
|
||||
context=context,
|
||||
extra_headers={
|
||||
'X-Ticket-ID': str(self.ticket.id),
|
||||
'X-Ticket-Type': self.ticket.ticket_type,
|
||||
},
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
def send_reply_notification_to_staff(self, comment: TicketComment) -> bool:
|
||||
@@ -392,25 +348,19 @@ class TicketEmailService:
|
||||
return False
|
||||
|
||||
context = self._get_base_context()
|
||||
context['ASSIGNEE_NAME'] = self.ticket.assignee.get_full_name() or self.ticket.assignee.email
|
||||
context['REPLY_MESSAGE'] = comment.comment_text
|
||||
context['assignee_name'] = self.ticket.assignee.get_full_name() or self.ticket.assignee.email
|
||||
context['reply_message'] = comment.comment_text
|
||||
context['recipient_name'] = context['assignee_name']
|
||||
|
||||
template = self._get_email_template(self.TEMPLATE_REPLY_STAFF)
|
||||
|
||||
if template:
|
||||
subject = self._render_template_variables(template.subject, context)
|
||||
html_content = self._render_template_variables(template.html_content, context)
|
||||
text_content = self._render_template_variables(template.text_content, context)
|
||||
else:
|
||||
subject = f"[Ticket #{self.ticket.id}] New reply from customer: {self.ticket.subject}"
|
||||
text_content = self._get_default_reply_staff_text(context)
|
||||
html_content = ''
|
||||
|
||||
return self._send_email(
|
||||
return send_system_email(
|
||||
email_type=EmailType.TICKET_REPLY,
|
||||
to_email=self.ticket.assignee.email,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content
|
||||
context=context,
|
||||
extra_headers={
|
||||
'X-Ticket-ID': str(self.ticket.id),
|
||||
'X-Ticket-Type': self.ticket.ticket_type,
|
||||
},
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
def send_reply_notification_to_customer(self, comment: TicketComment) -> bool:
|
||||
@@ -452,26 +402,19 @@ class TicketEmailService:
|
||||
return False
|
||||
|
||||
context = self._get_base_context()
|
||||
context['REPLY_MESSAGE'] = comment.comment_text
|
||||
context['CUSTOMER_NAME'] = recipient_name
|
||||
context['reply_message'] = comment.comment_text
|
||||
context['customer_name'] = recipient_name
|
||||
context['recipient_name'] = recipient_name
|
||||
|
||||
template = self._get_email_template(self.TEMPLATE_REPLY_CUSTOMER)
|
||||
|
||||
if template:
|
||||
subject = self._render_template_variables(template.subject, context)
|
||||
html_content = self._render_template_variables(template.html_content, context)
|
||||
text_content = self._render_template_variables(template.text_content, context)
|
||||
else:
|
||||
business_name = context['BUSINESS_NAME']
|
||||
subject = f"[Ticket #{self.ticket.id}] {business_name} has responded to your request"
|
||||
text_content = self._get_default_reply_customer_text(context)
|
||||
html_content = ''
|
||||
|
||||
return self._send_email(
|
||||
return send_system_email(
|
||||
email_type=EmailType.TICKET_REPLY,
|
||||
to_email=recipient_email,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content
|
||||
context=context,
|
||||
extra_headers={
|
||||
'X-Ticket-ID': str(self.ticket.id),
|
||||
'X-Ticket-Type': self.ticket.ticket_type,
|
||||
},
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
def send_resolution_notification(self, resolution_message: str = '') -> bool:
|
||||
@@ -487,154 +430,34 @@ class TicketEmailService:
|
||||
"""
|
||||
# Determine recipient email - either from creator or external_email
|
||||
recipient_email = None
|
||||
recipient_name = None
|
||||
|
||||
if self.ticket.creator and self.ticket.creator.email:
|
||||
recipient_email = self.ticket.creator.email
|
||||
recipient_name = self.ticket.creator.get_full_name() or self.ticket.creator.email
|
||||
elif self.ticket.external_email:
|
||||
recipient_email = self.ticket.external_email
|
||||
recipient_name = self.ticket.external_name or self.ticket.external_email
|
||||
|
||||
if not recipient_email:
|
||||
return False
|
||||
|
||||
context = self._get_base_context()
|
||||
context['RESOLUTION_MESSAGE'] = resolution_message or 'Your request has been resolved.'
|
||||
context['resolution_message'] = resolution_message or 'Your request has been resolved.'
|
||||
context['customer_name'] = recipient_name
|
||||
context['recipient_name'] = recipient_name
|
||||
|
||||
template = self._get_email_template(self.TEMPLATE_RESOLVED)
|
||||
|
||||
if template:
|
||||
subject = self._render_template_variables(template.subject, context)
|
||||
html_content = self._render_template_variables(template.html_content, context)
|
||||
text_content = self._render_template_variables(template.text_content, context)
|
||||
else:
|
||||
subject = f"[Ticket #{self.ticket.id}] Your request has been resolved"
|
||||
text_content = self._get_default_resolution_text(context)
|
||||
html_content = ''
|
||||
|
||||
return self._send_email(
|
||||
return send_system_email(
|
||||
email_type=EmailType.TICKET_RESOLVED,
|
||||
to_email=recipient_email,
|
||||
subject=subject,
|
||||
html_content=html_content,
|
||||
text_content=text_content
|
||||
context=context,
|
||||
extra_headers={
|
||||
'X-Ticket-ID': str(self.ticket.id),
|
||||
'X-Ticket-Type': self.ticket.ticket_type,
|
||||
},
|
||||
fail_silently=True,
|
||||
)
|
||||
|
||||
# ========== Default Text Templates (fallback) ==========
|
||||
|
||||
def _get_default_assignment_text(self, context: Dict[str, Any]) -> str:
|
||||
return f"""New Ticket Assigned to You
|
||||
|
||||
Hi {context['ASSIGNEE_NAME']},
|
||||
|
||||
A ticket has been assigned to you and requires your attention.
|
||||
|
||||
TICKET DETAILS
|
||||
--------------
|
||||
Ticket: #{context['TICKET_ID']}
|
||||
Subject: {context['TICKET_SUBJECT']}
|
||||
Priority: {context['TICKET_PRIORITY']}
|
||||
From: {context['TICKET_CUSTOMER_NAME']}
|
||||
|
||||
Message:
|
||||
{context['TICKET_MESSAGE']}
|
||||
|
||||
View ticket: {context['TICKET_URL']}
|
||||
|
||||
Please respond as soon as possible.
|
||||
|
||||
---
|
||||
{context['BUSINESS_NAME']}
|
||||
"""
|
||||
|
||||
def _get_default_status_change_text(self, context: Dict[str, Any]) -> str:
|
||||
return f"""Ticket Status Updated
|
||||
|
||||
Hi {context['RECIPIENT_NAME']},
|
||||
|
||||
The status of ticket #{context['TICKET_ID']} has been updated.
|
||||
|
||||
TICKET DETAILS
|
||||
--------------
|
||||
Ticket: #{context['TICKET_ID']}
|
||||
Subject: {context['TICKET_SUBJECT']}
|
||||
New Status: {context['TICKET_STATUS']}
|
||||
|
||||
View ticket: {context['TICKET_URL']}
|
||||
|
||||
---
|
||||
{context['BUSINESS_NAME']}
|
||||
"""
|
||||
|
||||
def _get_default_reply_staff_text(self, context: Dict[str, Any]) -> str:
|
||||
return f"""New Reply on Ticket #{context['TICKET_ID']}
|
||||
|
||||
Hi {context['ASSIGNEE_NAME']},
|
||||
|
||||
{context['TICKET_CUSTOMER_NAME']} has replied to ticket #{context['TICKET_ID']}.
|
||||
|
||||
Subject: {context['TICKET_SUBJECT']}
|
||||
|
||||
Reply:
|
||||
{context['REPLY_MESSAGE']}
|
||||
|
||||
View & reply: {context['TICKET_URL']}
|
||||
|
||||
---
|
||||
{context['BUSINESS_NAME']}
|
||||
"""
|
||||
|
||||
def _get_default_reply_customer_text(self, context: Dict[str, Any]) -> str:
|
||||
return f"""We've Responded to Your Request
|
||||
|
||||
Hi {context['CUSTOMER_NAME']},
|
||||
|
||||
We've replied to your support request.
|
||||
|
||||
TICKET DETAILS
|
||||
--------------
|
||||
Ticket: #{context['TICKET_ID']}
|
||||
Subject: {context['TICKET_SUBJECT']}
|
||||
|
||||
Our Response:
|
||||
{context['REPLY_MESSAGE']}
|
||||
|
||||
Need to reply?
|
||||
Simply reply to this email or visit: {context['TICKET_URL']}
|
||||
|
||||
Thank you for contacting us!
|
||||
The {context['BUSINESS_NAME']} Team
|
||||
|
||||
---
|
||||
{context['BUSINESS_NAME']}
|
||||
{context['BUSINESS_EMAIL']}
|
||||
{context['BUSINESS_PHONE']}
|
||||
"""
|
||||
|
||||
def _get_default_resolution_text(self, context: Dict[str, Any]) -> str:
|
||||
return f"""Your Request Has Been Resolved
|
||||
|
||||
Hi {context['CUSTOMER_NAME']},
|
||||
|
||||
Great news! Your support request has been resolved.
|
||||
|
||||
Ticket #{context['TICKET_ID']} - RESOLVED
|
||||
|
||||
Subject: {context['TICKET_SUBJECT']}
|
||||
Resolution: {context['RESOLUTION_MESSAGE']}
|
||||
|
||||
Not satisfied with the resolution?
|
||||
You can reopen this ticket by replying to this email within the next 7 days.
|
||||
|
||||
View ticket history: {context['TICKET_URL']}
|
||||
|
||||
Thank you for your patience!
|
||||
The {context['BUSINESS_NAME']} Team
|
||||
|
||||
---
|
||||
{context['BUSINESS_NAME']}
|
||||
{context['BUSINESS_EMAIL']}
|
||||
{context['BUSINESS_PHONE']}
|
||||
"""
|
||||
|
||||
|
||||
# ========== Convenience Functions ==========
|
||||
|
||||
def notify_ticket_assigned(ticket: Ticket) -> bool:
|
||||
|
||||
+236
-770
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
"""
|
||||
Default Email Templates
|
||||
|
||||
Provides default Puck templates for all email types.
|
||||
These are used when a tenant hasn't customized their templates.
|
||||
"""
|
||||
|
||||
DEFAULT_TEMPLATES = {
|
||||
# =========================================================================
|
||||
# Welcome
|
||||
# =========================================================================
|
||||
'welcome': {
|
||||
'subject_template': 'Welcome to {{ business_name }}!',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {
|
||||
'businessName': '{{ business_name }}',
|
||||
'preheader': "We're excited to have you!"
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Welcome, {{ customer_first_name }}!',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': "Thank you for choosing {{ business_name }}. We're thrilled to have you as a customer and look forward to serving you.",
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Book Your First Appointment',
|
||||
'href': '{{ business_website_url }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailSpacer',
|
||||
'props': {'size': 'md'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': "If you have any questions, don't hesitate to reach out to us at {{ business_email }} or call {{ business_phone }}.",
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'address': '{{ business_address }}',
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}',
|
||||
'website': '{{ business_website_url }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Appointment Confirmation
|
||||
# =========================================================================
|
||||
'appointment_confirmation': {
|
||||
'subject_template': 'Appointment Confirmed - {{ service_name }} on {{ appointment_date }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {
|
||||
'businessName': '{{ business_name }}',
|
||||
'preheader': 'Your appointment has been confirmed'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Your Appointment is Confirmed!',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nGreat news! Your appointment has been confirmed. Here are the details:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Service:</strong> {{ service_name }}<br><strong>Date:</strong> {{ appointment_date }}<br><strong>Time:</strong> {{ appointment_time }}<br><strong>Staff:</strong> {{ staff_name }}<br><strong>Location:</strong> {{ location_name }}',
|
||||
'backgroundColor': '#f3f4f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Manage Appointment',
|
||||
'href': '{{ manage_appointment_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailSpacer',
|
||||
'props': {'size': 'sm'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Add to Calendar',
|
||||
'href': '{{ add_to_calendar_link }}',
|
||||
'variant': 'secondary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailSpacer',
|
||||
'props': {'size': 'md'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Need to make changes? You can reschedule or cancel your appointment using the link above.',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'address': '{{ business_address }}',
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Appointment Reminder
|
||||
# =========================================================================
|
||||
'appointment_reminder': {
|
||||
'subject_template': 'Reminder: Your appointment tomorrow at {{ appointment_time }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {
|
||||
'businessName': '{{ business_name }}',
|
||||
'preheader': "Don't forget your appointment tomorrow!"
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Appointment Reminder',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nThis is a friendly reminder about your upcoming appointment:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>{{ service_name }}</strong><br>{{ appointment_date }} at {{ appointment_time }}<br>with {{ staff_name }}<br><br>{{ location_name }}<br>{{ location_address }}',
|
||||
'backgroundColor': '#fef3c7'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'View Appointment Details',
|
||||
'href': '{{ manage_appointment_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailSpacer',
|
||||
'props': {'size': 'md'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': "Can't make it? Please let us know as soon as possible so we can reschedule.",
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Reschedule',
|
||||
'href': '{{ reschedule_link }}',
|
||||
'variant': 'secondary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Appointment Rescheduled
|
||||
# =========================================================================
|
||||
'appointment_rescheduled': {
|
||||
'subject_template': 'Appointment Rescheduled - New time: {{ appointment_date }} at {{ appointment_time }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Your Appointment Has Been Rescheduled',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nYour appointment has been rescheduled. Here are your new appointment details:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>New Date & Time:</strong><br>{{ appointment_date }} at {{ appointment_time }}<br><br><strong>Service:</strong> {{ service_name }}<br><strong>Staff:</strong> {{ staff_name }}<br><strong>Location:</strong> {{ location_name }}',
|
||||
'backgroundColor': '#dbeafe'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'View Appointment',
|
||||
'href': '{{ manage_appointment_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Appointment Cancelled
|
||||
# =========================================================================
|
||||
'appointment_cancelled': {
|
||||
'subject_template': 'Appointment Cancelled - {{ service_name }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Appointment Cancelled',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nYour appointment has been cancelled:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>{{ service_name }}</strong><br>Originally scheduled for {{ appointment_date }} at {{ appointment_time }}',
|
||||
'backgroundColor': '#fee2e2'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': "We'd love to see you again! Feel free to book a new appointment at your convenience.",
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Book New Appointment',
|
||||
'href': '{{ business_website_url }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Thank You / Follow-up
|
||||
# =========================================================================
|
||||
'thank_you': {
|
||||
'subject_template': 'Thank you for visiting {{ business_name }}!',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Thank You, {{ customer_first_name }}!',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Thank you for your recent visit to {{ business_name }}. We hope you had a great experience!\n\nYour feedback means the world to us. If you have a moment, we would love to hear about your experience.',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Leave a Review',
|
||||
'href': '{{ review_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailSpacer',
|
||||
'props': {'size': 'md'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Ready to book your next appointment?',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Book Again',
|
||||
'href': '{{ business_website_url }}',
|
||||
'variant': 'secondary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'address': '{{ business_address }}',
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Contract Signing Request
|
||||
# =========================================================================
|
||||
'contract_signing_request': {
|
||||
'subject_template': 'Please Sign: {{ contract_title }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Document Ready for Signature',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\n{{ business_name }} has sent you a document that requires your signature:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>{{ contract_title }}</strong><br><br>{{ contract_description }}',
|
||||
'backgroundColor': '#f3f4f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Review & Sign',
|
||||
'href': '{{ signing_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailSpacer',
|
||||
'props': {'size': 'md'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Please sign by {{ contract_expires_at }} to avoid any delays.',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Contract Reminder
|
||||
# =========================================================================
|
||||
'contract_reminder': {
|
||||
'subject_template': 'Reminder: Please sign {{ contract_title }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Signature Reminder',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nThis is a friendly reminder that the following document is still awaiting your signature:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>{{ contract_title }}</strong><br><br>Expires in {{ days_until_expiry }} days',
|
||||
'backgroundColor': '#fef3c7'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Sign Now',
|
||||
'href': '{{ signing_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Contract Signed
|
||||
# =========================================================================
|
||||
'contract_signed': {
|
||||
'subject_template': 'Contract Signed: {{ contract_title }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Contract Successfully Signed',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nThank you for signing the following document:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>{{ contract_title }}</strong><br><br>Signed on: {{ signed_at }}',
|
||||
'backgroundColor': '#d1fae5'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'A copy of the signed document has been saved for your records. If you have any questions, please contact us.',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Payment Receipt
|
||||
# =========================================================================
|
||||
'payment_receipt': {
|
||||
'subject_template': 'Payment Receipt from {{ business_name }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Payment Received',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nThank you for your payment. Here is your receipt:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Amount Paid:</strong> {{ amount_paid }}<br><strong>Invoice:</strong> {{ invoice_number }}<br><strong>Date:</strong> {{ current_date }}',
|
||||
'backgroundColor': '#d1fae5'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'View Receipt',
|
||||
'href': '{{ receipt_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'address': '{{ business_address }}',
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Invoice
|
||||
# =========================================================================
|
||||
'invoice': {
|
||||
'subject_template': 'Invoice {{ invoice_number }} from {{ business_name }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Invoice',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nPlease find your invoice below:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Invoice #:</strong> {{ invoice_number }}<br><strong>Amount Due:</strong> {{ amount_due }}<br><strong>Due Date:</strong> {{ payment_due_date }}',
|
||||
'backgroundColor': '#f3f4f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Pay Now',
|
||||
'href': '{{ payment_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'address': '{{ business_address }}',
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Payment Reminder
|
||||
# =========================================================================
|
||||
'payment_reminder': {
|
||||
'subject_template': 'Payment Reminder: Invoice {{ invoice_number }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Payment Reminder',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nThis is a friendly reminder that payment is due for the following invoice:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Invoice #:</strong> {{ invoice_number }}<br><strong>Amount Due:</strong> {{ amount_due }}<br><strong>Due Date:</strong> {{ payment_due_date }}',
|
||||
'backgroundColor': '#fef3c7'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'Pay Now',
|
||||
'href': '{{ payment_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'If you have already made this payment, please disregard this reminder.',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Ticket Assigned
|
||||
# =========================================================================
|
||||
'ticket_assigned': {
|
||||
'subject_template': '[Ticket #{{ ticket_id }}] Assigned: {{ ticket_subject }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'New Ticket Assigned',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ assignee_name }},\n\nA support ticket has been assigned to you:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Ticket #{{ ticket_id }}</strong><br><strong>Subject:</strong> {{ ticket_subject }}<br><strong>Priority:</strong> {{ ticket_priority }}<br><br>{{ ticket_message }}',
|
||||
'backgroundColor': '#f3f4f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'View Ticket',
|
||||
'href': '{{ ticket_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Ticket Reply
|
||||
# =========================================================================
|
||||
'ticket_reply': {
|
||||
'subject_template': 'Re: [Ticket #{{ ticket_id }}] {{ ticket_subject }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'New Reply on Your Ticket',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nThere is a new reply on your support ticket:',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Ticket #{{ ticket_id }}: {{ ticket_subject }}</strong><br><br>{{ reply_message }}',
|
||||
'backgroundColor': '#f3f4f6'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'View & Reply',
|
||||
'href': '{{ ticket_link }}',
|
||||
'variant': 'primary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'You can also reply directly to this email.',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
|
||||
# =========================================================================
|
||||
# Ticket Resolved
|
||||
# =========================================================================
|
||||
'ticket_resolved': {
|
||||
'subject_template': '[Ticket #{{ ticket_id }}] Resolved: {{ ticket_subject }}',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {
|
||||
'text': 'Your Ticket Has Been Resolved',
|
||||
'level': 'h1',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': 'Hi {{ customer_first_name }},\n\nGreat news! Your support ticket has been resolved.',
|
||||
'align': 'left'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailPanel',
|
||||
'props': {
|
||||
'content': '<strong>Ticket #{{ ticket_id }}</strong><br>{{ ticket_subject }}<br><br><strong>Status:</strong> Resolved',
|
||||
'backgroundColor': '#d1fae5'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {
|
||||
'content': "If you need further assistance or have any questions, don't hesitate to reach out.",
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {
|
||||
'text': 'View Ticket',
|
||||
'href': '{{ ticket_link }}',
|
||||
'variant': 'secondary',
|
||||
'align': 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_default_template(email_type: str) -> dict:
|
||||
"""Get the default template for an email type."""
|
||||
return DEFAULT_TEMPLATES.get(email_type, {
|
||||
'subject_template': f'{email_type.replace("_", " ").title()} Email',
|
||||
'puck_data': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeader',
|
||||
'props': {'businessName': '{{ business_name }}'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {'content': 'Hello {{ customer_first_name }},'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailFooter',
|
||||
'props': {
|
||||
'phone': '{{ business_phone }}',
|
||||
'email': '{{ business_email }}'
|
||||
}
|
||||
}
|
||||
],
|
||||
'root': {}
|
||||
}
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Email Service
|
||||
|
||||
Central service for sending emails using PuckEmailTemplate system.
|
||||
Provides a simple API for sending templated emails throughout the application.
|
||||
|
||||
Usage:
|
||||
from smoothschedule.communication.messaging.email_service import send_system_email
|
||||
|
||||
# Send a simple email
|
||||
send_system_email(
|
||||
email_type=EmailType.APPOINTMENT_CONFIRMATION,
|
||||
to_email='customer@example.com',
|
||||
context={
|
||||
'customer_name': 'John Doe',
|
||||
'appointment_date': '2024-01-15',
|
||||
'appointment_time': '10:00 AM',
|
||||
}
|
||||
)
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
||||
from .email_types import EmailType
|
||||
from .models import PuckEmailTemplate
|
||||
from .email_renderer import render_email
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_system_email(
|
||||
email_type: EmailType,
|
||||
to_email: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
from_email: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
fail_silently: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email using a system template.
|
||||
|
||||
Retrieves the appropriate PuckEmailTemplate for the email_type,
|
||||
renders it with the provided context, and sends via Django's email backend.
|
||||
|
||||
Args:
|
||||
email_type: EmailType enum value (e.g., EmailType.APPOINTMENT_CONFIRMATION)
|
||||
to_email: Recipient email address
|
||||
context: Dictionary of tag values for template rendering
|
||||
from_email: Optional sender email (defaults to DEFAULT_FROM_EMAIL)
|
||||
reply_to: Optional reply-to address
|
||||
extra_headers: Optional additional email headers
|
||||
fail_silently: If True, suppress exceptions on send failure
|
||||
|
||||
Returns:
|
||||
True if email sent successfully, False otherwise
|
||||
|
||||
Example:
|
||||
send_system_email(
|
||||
email_type=EmailType.WELCOME,
|
||||
to_email='new.user@example.com',
|
||||
context={'customer_name': 'John', 'business_name': 'Acme Corp'},
|
||||
)
|
||||
"""
|
||||
if not to_email:
|
||||
logger.warning("Cannot send email: no recipient address")
|
||||
return False
|
||||
|
||||
context = context or {}
|
||||
|
||||
try:
|
||||
# Get or create template for this email type
|
||||
template = PuckEmailTemplate.get_or_create_for_type(email_type)
|
||||
|
||||
if not template.is_active:
|
||||
logger.info(f"Email template {email_type} is inactive, skipping send")
|
||||
return False
|
||||
|
||||
# Render the email
|
||||
rendered = render_email(template, context)
|
||||
|
||||
# Build the email
|
||||
sender = from_email or getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=rendered['subject'],
|
||||
body=rendered['text'],
|
||||
from_email=sender,
|
||||
to=[to_email],
|
||||
)
|
||||
|
||||
# Add HTML version
|
||||
if rendered['html']:
|
||||
msg.attach_alternative(rendered['html'], 'text/html')
|
||||
|
||||
# Add reply-to if specified
|
||||
if reply_to:
|
||||
msg.reply_to = [reply_to]
|
||||
|
||||
# Add extra headers
|
||||
if extra_headers:
|
||||
msg.extra_headers = extra_headers
|
||||
|
||||
# Send it
|
||||
msg.send(fail_silently=fail_silently)
|
||||
logger.info(f"Sent {email_type.value} email to {to_email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send {email_type.value} email to {to_email}: {e}")
|
||||
if not fail_silently:
|
||||
raise
|
||||
return False
|
||||
|
||||
|
||||
def send_system_email_bulk(
|
||||
email_type: EmailType,
|
||||
recipients: List[Dict[str, Any]],
|
||||
common_context: Optional[Dict[str, Any]] = None,
|
||||
from_email: Optional[str] = None,
|
||||
fail_silently: bool = True,
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
Send the same email type to multiple recipients with individual context.
|
||||
|
||||
Args:
|
||||
email_type: EmailType enum value
|
||||
recipients: List of dicts with 'email' and optional 'context' keys
|
||||
common_context: Context values shared by all recipients
|
||||
from_email: Optional sender email
|
||||
fail_silently: If True, continue on individual send failures
|
||||
|
||||
Returns:
|
||||
Dict mapping email addresses to success status
|
||||
|
||||
Example:
|
||||
send_system_email_bulk(
|
||||
email_type=EmailType.APPOINTMENT_REMINDER,
|
||||
recipients=[
|
||||
{'email': 'customer1@example.com', 'context': {'customer_name': 'Alice'}},
|
||||
{'email': 'customer2@example.com', 'context': {'customer_name': 'Bob'}},
|
||||
],
|
||||
common_context={'business_name': 'Acme Corp'},
|
||||
)
|
||||
"""
|
||||
results = {}
|
||||
common_context = common_context or {}
|
||||
|
||||
for recipient in recipients:
|
||||
email = recipient.get('email')
|
||||
if not email:
|
||||
continue
|
||||
|
||||
# Merge common context with recipient-specific context
|
||||
recipient_context = recipient.get('context', {})
|
||||
merged_context = {**common_context, **recipient_context}
|
||||
|
||||
success = send_system_email(
|
||||
email_type=email_type,
|
||||
to_email=email,
|
||||
context=merged_context,
|
||||
from_email=from_email,
|
||||
fail_silently=fail_silently,
|
||||
)
|
||||
results[email] = success
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def get_template_preview(
|
||||
email_type: EmailType,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Get a preview of what an email would look like without sending.
|
||||
|
||||
Args:
|
||||
email_type: EmailType enum value
|
||||
context: Dictionary of tag values for rendering
|
||||
|
||||
Returns:
|
||||
Dict with 'subject', 'html', and 'text' keys
|
||||
"""
|
||||
template = PuckEmailTemplate.get_or_create_for_type(email_type)
|
||||
return render_email(template, context or {})
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Email Template Tag System
|
||||
|
||||
Defines the allowlist of template tags and validation logic.
|
||||
Tags use {{ tag_name }} syntax in subject and body content.
|
||||
"""
|
||||
import re
|
||||
from typing import List, Dict, Set, Tuple
|
||||
from .email_types import EmailType
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tag Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Base tags available for ALL email types
|
||||
BASE_TAGS: Dict[str, str] = {
|
||||
# Business info
|
||||
'business_name': 'Business name',
|
||||
'business_email': 'Business email address',
|
||||
'business_phone': 'Business phone number',
|
||||
'business_address': 'Business address',
|
||||
'business_logo_url': 'URL to business logo image',
|
||||
'business_website_url': 'Business website URL',
|
||||
|
||||
# Customer info
|
||||
'customer_name': 'Customer full name',
|
||||
'customer_first_name': 'Customer first name',
|
||||
'customer_last_name': 'Customer last name',
|
||||
'customer_email': 'Customer email address',
|
||||
'customer_phone': 'Customer phone number',
|
||||
|
||||
# Date/time
|
||||
'current_date': 'Current date (formatted)',
|
||||
'current_year': 'Current year',
|
||||
}
|
||||
|
||||
# Appointment-related tags
|
||||
APPOINTMENT_TAGS: Dict[str, str] = {
|
||||
'appointment_date': 'Appointment date (e.g., December 15, 2024)',
|
||||
'appointment_time': 'Appointment time (e.g., 2:00 PM)',
|
||||
'appointment_datetime': 'Full date and time',
|
||||
'appointment_duration': 'Duration (e.g., 1 hour)',
|
||||
'service_name': 'Name of the booked service',
|
||||
'service_description': 'Service description',
|
||||
'staff_name': 'Assigned staff member name',
|
||||
'location_name': 'Location/venue name',
|
||||
'location_address': 'Location address',
|
||||
|
||||
# Action links
|
||||
'manage_appointment_link': 'Link to manage appointment',
|
||||
'reschedule_link': 'Link to reschedule',
|
||||
'cancel_link': 'Link to cancel',
|
||||
'add_to_calendar_link': 'Add to calendar link (ICS)',
|
||||
}
|
||||
|
||||
# Contract-related tags
|
||||
CONTRACT_TAGS: Dict[str, str] = {
|
||||
'contract_title': 'Contract title/name',
|
||||
'contract_description': 'Contract description',
|
||||
'signing_link': 'Link to sign the contract',
|
||||
'contract_expires_at': 'Contract expiration date',
|
||||
'days_until_expiry': 'Days until contract expires',
|
||||
'signed_at': 'Date/time contract was signed',
|
||||
}
|
||||
|
||||
# Payment-related tags
|
||||
PAYMENT_TAGS: Dict[str, str] = {
|
||||
'amount_due': 'Amount due (formatted with currency)',
|
||||
'amount_paid': 'Amount paid (formatted with currency)',
|
||||
'currency': 'Currency code (e.g., USD)',
|
||||
'invoice_number': 'Invoice number/ID',
|
||||
'receipt_link': 'Link to view receipt',
|
||||
'payment_link': 'Link to make payment',
|
||||
'payment_due_date': 'Payment due date',
|
||||
}
|
||||
|
||||
# Ticket/support-related tags
|
||||
TICKET_TAGS: Dict[str, str] = {
|
||||
'ticket_id': 'Ticket ID/number',
|
||||
'ticket_subject': 'Ticket subject line',
|
||||
'ticket_status': 'Current ticket status',
|
||||
'ticket_priority': 'Ticket priority level',
|
||||
'ticket_message': 'Original ticket message',
|
||||
'reply_message': 'Reply content',
|
||||
'ticket_link': 'Link to view ticket',
|
||||
'assignee_name': 'Name of assigned staff',
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tag Mapping by Email Type
|
||||
# =============================================================================
|
||||
|
||||
def get_tags_for_category(category: str) -> Dict[str, str]:
|
||||
"""Get additional tags for a category."""
|
||||
category_tags = {
|
||||
'appointment': APPOINTMENT_TAGS,
|
||||
'contract': CONTRACT_TAGS,
|
||||
'payment': PAYMENT_TAGS,
|
||||
'ticket': TICKET_TAGS,
|
||||
'welcome': {}, # Only base tags
|
||||
}
|
||||
return category_tags.get(category, {})
|
||||
|
||||
|
||||
def get_allowed_tags_for_email_type(email_type: EmailType) -> Dict[str, str]:
|
||||
"""
|
||||
Get all allowed tags for an email type.
|
||||
|
||||
Returns:
|
||||
Dictionary of tag_name -> description
|
||||
"""
|
||||
category = EmailType.get_category(email_type)
|
||||
category_tags = get_tags_for_category(category)
|
||||
|
||||
# Combine base tags with category-specific tags
|
||||
allowed = {**BASE_TAGS, **category_tags}
|
||||
|
||||
# Some email types need additional tags from other categories
|
||||
if email_type == EmailType.APPOINTMENT_CONFIRMATION:
|
||||
# Confirmation might include payment info if deposits are used
|
||||
allowed['deposit_amount'] = 'Deposit amount if applicable'
|
||||
|
||||
if email_type == EmailType.THANK_YOU:
|
||||
# Follow-up might include payment receipt info
|
||||
allowed.update({
|
||||
'total_paid': 'Total amount paid',
|
||||
'review_link': 'Link to leave a review',
|
||||
})
|
||||
|
||||
return allowed
|
||||
|
||||
|
||||
def get_allowed_tag_names(email_type: EmailType) -> Set[str]:
|
||||
"""Get just the tag names (without descriptions)."""
|
||||
return set(get_allowed_tags_for_email_type(email_type).keys())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tag Extraction and Validation
|
||||
# =============================================================================
|
||||
|
||||
# Regex to find {{ tag_name }} patterns
|
||||
TAG_PATTERN = re.compile(r'\{\{\s*(\w+)\s*\}\}')
|
||||
|
||||
|
||||
def extract_tags(text: str) -> Set[str]:
|
||||
"""
|
||||
Extract all tag names from a text string.
|
||||
|
||||
Args:
|
||||
text: Text containing {{ tag_name }} patterns
|
||||
|
||||
Returns:
|
||||
Set of tag names found
|
||||
"""
|
||||
if not text:
|
||||
return set()
|
||||
return set(TAG_PATTERN.findall(text))
|
||||
|
||||
|
||||
def extract_tags_from_puck_data(puck_data: dict) -> Set[str]:
|
||||
"""
|
||||
Extract all tags from Puck component data.
|
||||
|
||||
Searches through all text content in components.
|
||||
|
||||
Args:
|
||||
puck_data: Puck data structure with 'content' array
|
||||
|
||||
Returns:
|
||||
Set of tag names found
|
||||
"""
|
||||
tags = set()
|
||||
|
||||
def extract_from_value(value):
|
||||
"""Recursively extract tags from any value."""
|
||||
if isinstance(value, str):
|
||||
tags.update(extract_tags(value))
|
||||
elif isinstance(value, dict):
|
||||
for v in value.values():
|
||||
extract_from_value(v)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
extract_from_value(item)
|
||||
|
||||
extract_from_value(puck_data)
|
||||
return tags
|
||||
|
||||
|
||||
def validate_tags(
|
||||
subject: str,
|
||||
puck_data_or_body: str | dict,
|
||||
email_type: EmailType
|
||||
) -> List[str]:
|
||||
"""
|
||||
Validate that only allowed tags are used.
|
||||
|
||||
Args:
|
||||
subject: Subject line template
|
||||
puck_data_or_body: Either Puck data dict or plain text body
|
||||
email_type: The email type being validated
|
||||
|
||||
Returns:
|
||||
List of error messages (empty if valid)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Get allowed tags
|
||||
allowed_tags = get_allowed_tag_names(email_type)
|
||||
|
||||
# Extract tags from subject
|
||||
subject_tags = extract_tags(subject)
|
||||
|
||||
# Extract tags from body
|
||||
if isinstance(puck_data_or_body, dict):
|
||||
body_tags = extract_tags_from_puck_data(puck_data_or_body)
|
||||
else:
|
||||
body_tags = extract_tags(puck_data_or_body)
|
||||
|
||||
# Find invalid tags
|
||||
all_used_tags = subject_tags | body_tags
|
||||
invalid_tags = all_used_tags - allowed_tags
|
||||
|
||||
for tag in invalid_tags:
|
||||
errors.append(
|
||||
f"Invalid tag '{{{{ {tag} }}}}' is not allowed for {email_type.value} emails. "
|
||||
f"Allowed tags: {', '.join(sorted(allowed_tags))}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def get_tag_info_for_email_type(email_type: EmailType) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get tag information for UI display.
|
||||
|
||||
Returns list of dicts with 'name', 'description', and 'category'.
|
||||
"""
|
||||
result = []
|
||||
allowed = get_allowed_tags_for_email_type(email_type)
|
||||
|
||||
# Categorize tags
|
||||
for tag, description in sorted(allowed.items()):
|
||||
if tag in BASE_TAGS:
|
||||
category = 'Business & Customer'
|
||||
elif tag in APPOINTMENT_TAGS:
|
||||
category = 'Appointment'
|
||||
elif tag in CONTRACT_TAGS:
|
||||
category = 'Contract'
|
||||
elif tag in PAYMENT_TAGS:
|
||||
category = 'Payment'
|
||||
elif tag in TICKET_TAGS:
|
||||
category = 'Support Ticket'
|
||||
else:
|
||||
category = 'Other'
|
||||
|
||||
result.append({
|
||||
'name': tag,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'syntax': f'{{{{ {tag} }}}}',
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_tag_info() -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get ALL available tags for custom email templates.
|
||||
|
||||
Custom templates (used by plugins) have access to all tags
|
||||
since they may be triggered by any context.
|
||||
|
||||
Returns list of dicts with 'name', 'description', and 'category'.
|
||||
"""
|
||||
result = []
|
||||
|
||||
# Collect all tags with their categories
|
||||
tag_categories = [
|
||||
(BASE_TAGS, 'Business & Customer'),
|
||||
(APPOINTMENT_TAGS, 'Appointment'),
|
||||
(CONTRACT_TAGS, 'Contract'),
|
||||
(PAYMENT_TAGS, 'Payment'),
|
||||
(TICKET_TAGS, 'Support Ticket'),
|
||||
]
|
||||
|
||||
seen_tags = set()
|
||||
|
||||
for tag_dict, category in tag_categories:
|
||||
for tag, description in sorted(tag_dict.items()):
|
||||
if tag not in seen_tags:
|
||||
seen_tags.add(tag)
|
||||
result.append({
|
||||
'name': tag,
|
||||
'description': description,
|
||||
'category': category,
|
||||
'syntax': f'{{{{ {tag} }}}}',
|
||||
})
|
||||
|
||||
# Sort by category then name
|
||||
result.sort(key=lambda x: (x['category'], x['name']))
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Email Type Enum
|
||||
|
||||
Defines all system email types that can have customizable templates.
|
||||
Each business (tenant) has one template per email type.
|
||||
"""
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EmailType(str, Enum):
|
||||
"""
|
||||
Enumeration of all email types in the system.
|
||||
|
||||
Email types are grouped by category:
|
||||
- Welcome/Onboarding
|
||||
- Appointment-related
|
||||
- Contract-related
|
||||
- Payment-related
|
||||
- Ticket/Support-related
|
||||
"""
|
||||
|
||||
# ==========================================================================
|
||||
# Welcome / Onboarding
|
||||
# ==========================================================================
|
||||
WELCOME = 'welcome'
|
||||
"""Sent to new customers after registration or first booking."""
|
||||
|
||||
# ==========================================================================
|
||||
# Appointment Lifecycle
|
||||
# ==========================================================================
|
||||
APPOINTMENT_CONFIRMATION = 'appointment_confirmation'
|
||||
"""Sent immediately after booking is confirmed."""
|
||||
|
||||
APPOINTMENT_REMINDER = 'appointment_reminder'
|
||||
"""Sent before appointment (24h, 1h, etc.)."""
|
||||
|
||||
APPOINTMENT_RESCHEDULED = 'appointment_rescheduled'
|
||||
"""Sent when appointment time is changed."""
|
||||
|
||||
APPOINTMENT_CANCELLED = 'appointment_cancelled'
|
||||
"""Sent when appointment is cancelled."""
|
||||
|
||||
THANK_YOU = 'thank_you'
|
||||
"""Sent after appointment is completed (follow-up)."""
|
||||
|
||||
# ==========================================================================
|
||||
# Contract / E-Signature
|
||||
# ==========================================================================
|
||||
CONTRACT_SIGNING_REQUEST = 'contract_signing_request'
|
||||
"""Sent when a contract is created and needs signature."""
|
||||
|
||||
CONTRACT_REMINDER = 'contract_reminder'
|
||||
"""Reminder to sign pending contract."""
|
||||
|
||||
CONTRACT_SIGNED = 'contract_signed'
|
||||
"""Confirmation that contract was successfully signed."""
|
||||
|
||||
# ==========================================================================
|
||||
# Payment
|
||||
# ==========================================================================
|
||||
PAYMENT_RECEIPT = 'payment_receipt'
|
||||
"""Receipt sent after successful payment."""
|
||||
|
||||
INVOICE = 'invoice'
|
||||
"""Invoice sent for payment due."""
|
||||
|
||||
PAYMENT_REMINDER = 'payment_reminder'
|
||||
"""Reminder for unpaid invoice."""
|
||||
|
||||
# ==========================================================================
|
||||
# Support Tickets
|
||||
# ==========================================================================
|
||||
TICKET_ASSIGNED = 'ticket_assigned'
|
||||
"""Sent to staff when ticket is assigned to them."""
|
||||
|
||||
TICKET_REPLY = 'ticket_reply'
|
||||
"""Sent when someone replies to a ticket."""
|
||||
|
||||
TICKET_RESOLVED = 'ticket_resolved'
|
||||
"""Sent when ticket is marked as resolved."""
|
||||
|
||||
# ==========================================================================
|
||||
# Utility Methods
|
||||
# ==========================================================================
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
"""Return choices tuple for Django model field."""
|
||||
return [(e.value, e.value.replace('_', ' ').title()) for e in cls]
|
||||
|
||||
@classmethod
|
||||
def get_category(cls, email_type: 'EmailType') -> str:
|
||||
"""Get the category for an email type."""
|
||||
categories = {
|
||||
# Welcome
|
||||
cls.WELCOME: 'welcome',
|
||||
# Appointment
|
||||
cls.APPOINTMENT_CONFIRMATION: 'appointment',
|
||||
cls.APPOINTMENT_REMINDER: 'appointment',
|
||||
cls.APPOINTMENT_RESCHEDULED: 'appointment',
|
||||
cls.APPOINTMENT_CANCELLED: 'appointment',
|
||||
cls.THANK_YOU: 'appointment',
|
||||
# Contract
|
||||
cls.CONTRACT_SIGNING_REQUEST: 'contract',
|
||||
cls.CONTRACT_REMINDER: 'contract',
|
||||
cls.CONTRACT_SIGNED: 'contract',
|
||||
# Payment
|
||||
cls.PAYMENT_RECEIPT: 'payment',
|
||||
cls.INVOICE: 'payment',
|
||||
cls.PAYMENT_REMINDER: 'payment',
|
||||
# Ticket
|
||||
cls.TICKET_ASSIGNED: 'ticket',
|
||||
cls.TICKET_REPLY: 'ticket',
|
||||
cls.TICKET_RESOLVED: 'ticket',
|
||||
}
|
||||
return categories.get(email_type, 'other')
|
||||
|
||||
@classmethod
|
||||
def get_display_name(cls, email_type: 'EmailType') -> str:
|
||||
"""Get human-readable display name for email type."""
|
||||
display_names = {
|
||||
cls.WELCOME: 'Welcome Email',
|
||||
cls.APPOINTMENT_CONFIRMATION: 'Appointment Confirmation',
|
||||
cls.APPOINTMENT_REMINDER: 'Appointment Reminder',
|
||||
cls.APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
|
||||
cls.APPOINTMENT_CANCELLED: 'Appointment Cancelled',
|
||||
cls.THANK_YOU: 'Thank You / Follow-up',
|
||||
cls.CONTRACT_SIGNING_REQUEST: 'Contract Signing Request',
|
||||
cls.CONTRACT_REMINDER: 'Contract Reminder',
|
||||
cls.CONTRACT_SIGNED: 'Contract Signed Confirmation',
|
||||
cls.PAYMENT_RECEIPT: 'Payment Receipt',
|
||||
cls.INVOICE: 'Invoice',
|
||||
cls.PAYMENT_REMINDER: 'Payment Reminder',
|
||||
cls.TICKET_ASSIGNED: 'Ticket Assigned',
|
||||
cls.TICKET_REPLY: 'Ticket Reply',
|
||||
cls.TICKET_RESOLVED: 'Ticket Resolved',
|
||||
}
|
||||
return display_names.get(email_type, email_type.value.replace('_', ' ').title())
|
||||
|
||||
@classmethod
|
||||
def get_description(cls, email_type: 'EmailType') -> str:
|
||||
"""Get description of when this email is sent."""
|
||||
descriptions = {
|
||||
cls.WELCOME: 'Sent to new customers after registration or first booking',
|
||||
cls.APPOINTMENT_CONFIRMATION: 'Sent immediately after an appointment is confirmed',
|
||||
cls.APPOINTMENT_REMINDER: 'Sent before the appointment (e.g., 24 hours, 1 hour)',
|
||||
cls.APPOINTMENT_RESCHEDULED: 'Sent when appointment date/time is changed',
|
||||
cls.APPOINTMENT_CANCELLED: 'Sent when an appointment is cancelled',
|
||||
cls.THANK_YOU: 'Sent after appointment completion as a follow-up',
|
||||
cls.CONTRACT_SIGNING_REQUEST: 'Sent when a contract requires customer signature',
|
||||
cls.CONTRACT_REMINDER: 'Reminder to sign a pending contract',
|
||||
cls.CONTRACT_SIGNED: 'Confirmation that the contract was signed',
|
||||
cls.PAYMENT_RECEIPT: 'Receipt sent after successful payment',
|
||||
cls.INVOICE: 'Invoice sent for services requiring payment',
|
||||
cls.PAYMENT_REMINDER: 'Reminder for unpaid invoices',
|
||||
cls.TICKET_ASSIGNED: 'Notifies staff when a support ticket is assigned',
|
||||
cls.TICKET_REPLY: 'Sent when someone replies to a support ticket',
|
||||
cls.TICKET_RESOLVED: 'Sent when a support ticket is resolved',
|
||||
}
|
||||
return descriptions.get(email_type, '')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# EMAIL_TYPE_INFO Dictionary
|
||||
# =============================================================================
|
||||
# Pre-built dictionary with all email type information for easy consumption
|
||||
# by external modules like safe_scripting.
|
||||
|
||||
EMAIL_TYPE_INFO = {
|
||||
email_type: {
|
||||
'display_name': EmailType.get_display_name(email_type),
|
||||
'description': EmailType.get_description(email_type),
|
||||
'category': EmailType.get_category(email_type),
|
||||
}
|
||||
for email_type in EmailType
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Seed Email Templates Management Command
|
||||
|
||||
Seeds default email templates for all existing tenants.
|
||||
Run this after deploying the new email template system to ensure
|
||||
all existing tenants have their system email templates.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_tenants.utils import schema_context, get_tenant_model
|
||||
|
||||
from smoothschedule.communication.messaging.models import PuckEmailTemplate
|
||||
from smoothschedule.communication.messaging.email_types import EmailType
|
||||
from smoothschedule.communication.messaging.default_templates import DEFAULT_TEMPLATES
|
||||
from smoothschedule.communication.messaging.utils import add_component_ids
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seed default email templates for all tenants'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--tenant',
|
||||
type=str,
|
||||
help='Only seed templates for this specific tenant schema',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Reset all templates to defaults (overwrites customizations)',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
Tenant = get_tenant_model()
|
||||
|
||||
if options['tenant']:
|
||||
# Single tenant mode
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name=options['tenant'])
|
||||
tenants = [tenant]
|
||||
except Tenant.DoesNotExist:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"Tenant '{options['tenant']}' not found")
|
||||
)
|
||||
return
|
||||
else:
|
||||
# All tenants (excluding public schema)
|
||||
tenants = Tenant.objects.exclude(schema_name='public')
|
||||
|
||||
self.stdout.write(f"Seeding email templates for {len(tenants)} tenant(s)...")
|
||||
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
|
||||
for tenant in tenants:
|
||||
created, updated = self._seed_for_tenant(
|
||||
tenant.schema_name,
|
||||
force=options['force']
|
||||
)
|
||||
total_created += created
|
||||
total_updated += updated
|
||||
|
||||
if created > 0 or updated > 0:
|
||||
self.stdout.write(
|
||||
f" {tenant.schema_name}: created={created}, updated={updated}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\nDone! Created {total_created}, updated {total_updated} templates."
|
||||
)
|
||||
)
|
||||
|
||||
def _seed_for_tenant(self, schema_name: str, force: bool = False) -> tuple:
|
||||
"""Seed email templates for a single tenant."""
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
|
||||
with schema_context(schema_name):
|
||||
for email_type in EmailType:
|
||||
default_data = DEFAULT_TEMPLATES.get(email_type.value)
|
||||
if not default_data:
|
||||
continue
|
||||
|
||||
existing = PuckEmailTemplate.objects.filter(
|
||||
email_type=email_type.value
|
||||
).first()
|
||||
|
||||
# Add unique IDs to components (required by Puck)
|
||||
puck_data_with_ids = add_component_ids(default_data['puck_data'])
|
||||
|
||||
if existing:
|
||||
if force:
|
||||
# Reset to default
|
||||
existing.subject_template = default_data['subject_template']
|
||||
existing.puck_data = puck_data_with_ids
|
||||
existing.is_customized = False
|
||||
existing.save()
|
||||
updated_count += 1
|
||||
# Skip if not forcing
|
||||
else:
|
||||
# Create new template
|
||||
PuckEmailTemplate.objects.create(
|
||||
email_type=email_type.value,
|
||||
subject_template=default_data['subject_template'],
|
||||
puck_data=puck_data_with_ids,
|
||||
is_active=True,
|
||||
is_customized=False,
|
||||
)
|
||||
created_count += 1
|
||||
|
||||
return created_count, updated_count
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Management command to update email templates, replacing 'tenant_' with 'business_'.
|
||||
|
||||
This updates both PuckEmailTemplate and CustomEmailTemplate across all tenants.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_tenants.utils import schema_context, get_tenant_model
|
||||
|
||||
from smoothschedule.communication.messaging.models import PuckEmailTemplate, CustomEmailTemplate
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Replace tenant_ with business_ in all email templates'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be changed without actually changing it',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN - no changes will be made'))
|
||||
|
||||
Tenant = get_tenant_model()
|
||||
tenants = Tenant.objects.exclude(schema_name='public')
|
||||
|
||||
total_puck_updated = 0
|
||||
total_custom_updated = 0
|
||||
|
||||
for tenant in tenants:
|
||||
self.stdout.write(f'\nProcessing tenant: {tenant.name} ({tenant.schema_name})')
|
||||
|
||||
with schema_context(tenant.schema_name):
|
||||
# Update PuckEmailTemplate
|
||||
puck_count = self._update_puck_templates(dry_run)
|
||||
total_puck_updated += puck_count
|
||||
|
||||
# Update CustomEmailTemplate
|
||||
custom_count = self._update_custom_templates(dry_run)
|
||||
total_custom_updated += custom_count
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\nTotal updated: {total_puck_updated} PuckEmailTemplates, '
|
||||
f'{total_custom_updated} CustomEmailTemplates'
|
||||
))
|
||||
|
||||
def _replace_tenant_with_business(self, text):
|
||||
"""Replace tenant_ with business_ in a string."""
|
||||
if not text:
|
||||
return text, False
|
||||
|
||||
# Pattern to match tenant_ prefix (e.g., tenant_name, tenant_email)
|
||||
pattern = r'\{\{\s*tenant_(\w+)\s*\}\}'
|
||||
replacement = r'{{ business_\1 }}'
|
||||
|
||||
new_text = re.sub(pattern, replacement, text)
|
||||
changed = new_text != text
|
||||
return new_text, changed
|
||||
|
||||
def _replace_in_json(self, data):
|
||||
"""Recursively replace tenant_ with business_ in JSON structure."""
|
||||
changed = False
|
||||
|
||||
if isinstance(data, str):
|
||||
new_data, was_changed = self._replace_tenant_with_business(data)
|
||||
return new_data, was_changed
|
||||
elif isinstance(data, dict):
|
||||
new_dict = {}
|
||||
for key, value in data.items():
|
||||
new_value, was_changed = self._replace_in_json(value)
|
||||
new_dict[key] = new_value
|
||||
if was_changed:
|
||||
changed = True
|
||||
return new_dict, changed
|
||||
elif isinstance(data, list):
|
||||
new_list = []
|
||||
for item in data:
|
||||
new_item, was_changed = self._replace_in_json(item)
|
||||
new_list.append(new_item)
|
||||
if was_changed:
|
||||
changed = True
|
||||
return new_list, changed
|
||||
else:
|
||||
return data, False
|
||||
|
||||
def _update_puck_templates(self, dry_run):
|
||||
"""Update PuckEmailTemplate records."""
|
||||
updated_count = 0
|
||||
|
||||
for template in PuckEmailTemplate.objects.all():
|
||||
subject_changed = False
|
||||
puck_changed = False
|
||||
|
||||
# Check and update subject
|
||||
new_subject, subject_changed = self._replace_tenant_with_business(
|
||||
template.subject_template
|
||||
)
|
||||
|
||||
# Check and update puck_data
|
||||
new_puck_data, puck_changed = self._replace_in_json(template.puck_data)
|
||||
|
||||
if subject_changed or puck_changed:
|
||||
self.stdout.write(
|
||||
f' PuckEmailTemplate [{template.email_type}]: '
|
||||
f'subject_changed={subject_changed}, puck_changed={puck_changed}'
|
||||
)
|
||||
|
||||
if subject_changed:
|
||||
self.stdout.write(f' Subject: {template.subject_template}')
|
||||
self.stdout.write(f' -> {new_subject}')
|
||||
|
||||
if not dry_run:
|
||||
template.subject_template = new_subject
|
||||
template.puck_data = new_puck_data
|
||||
# Skip validation to allow any changes
|
||||
PuckEmailTemplate.objects.filter(pk=template.pk).update(
|
||||
subject_template=new_subject,
|
||||
puck_data=new_puck_data
|
||||
)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
return updated_count
|
||||
|
||||
def _update_custom_templates(self, dry_run):
|
||||
"""Update CustomEmailTemplate records."""
|
||||
updated_count = 0
|
||||
|
||||
for template in CustomEmailTemplate.objects.all():
|
||||
subject_changed = False
|
||||
puck_changed = False
|
||||
|
||||
# Check and update subject
|
||||
new_subject, subject_changed = self._replace_tenant_with_business(
|
||||
template.subject_template
|
||||
)
|
||||
|
||||
# Check and update puck_data
|
||||
new_puck_data, puck_changed = self._replace_in_json(template.puck_data)
|
||||
|
||||
if subject_changed or puck_changed:
|
||||
self.stdout.write(
|
||||
f' CustomEmailTemplate [{template.slug}]: '
|
||||
f'subject_changed={subject_changed}, puck_changed={puck_changed}'
|
||||
)
|
||||
|
||||
if subject_changed:
|
||||
self.stdout.write(f' Subject: {template.subject_template}')
|
||||
self.stdout.write(f' -> {new_subject}')
|
||||
|
||||
if not dry_run:
|
||||
CustomEmailTemplate.objects.filter(pk=template.pk).update(
|
||||
subject_template=new_subject,
|
||||
puck_data=new_puck_data
|
||||
)
|
||||
|
||||
updated_count += 1
|
||||
|
||||
return updated_count
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-14 01:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import smoothschedule.communication.messaging.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0002_add_broadcast_messaging'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PuckEmailTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('email_type', models.CharField(choices=[('welcome', 'Welcome'), ('appointment_confirmation', 'Appointment Confirmation'), ('appointment_reminder', 'Appointment Reminder'), ('appointment_rescheduled', 'Appointment Rescheduled'), ('appointment_cancelled', 'Appointment Cancelled'), ('thank_you', 'Thank You'), ('contract_signing_request', 'Contract Signing Request'), ('contract_reminder', 'Contract Reminder'), ('contract_signed', 'Contract Signed'), ('payment_receipt', 'Payment Receipt'), ('invoice', 'Invoice'), ('payment_reminder', 'Payment Reminder'), ('ticket_assigned', 'Ticket Assigned'), ('ticket_reply', 'Ticket Reply'), ('ticket_resolved', 'Ticket Resolved')], db_index=True, help_text='Type of email this template is for', max_length=50)),
|
||||
('subject_template', models.CharField(help_text='Email subject line. Supports tags like {{ customer_name }}', max_length=500)),
|
||||
('puck_data', models.JSONField(default=smoothschedule.communication.messaging.models.default_puck_data, help_text='Puck editor JSON data for email body')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this template is active')),
|
||||
('is_customized', models.BooleanField(default=False, help_text='Whether tenant has customized from default')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='User who created/last edited this template', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_email_templates_puck', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['email_type'],
|
||||
'indexes': [models.Index(fields=['email_type', 'is_active'], name='messaging_p_email_t_ac2087_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('email_type',), name='unique_email_type_per_tenant')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-14 21:01
|
||||
|
||||
import django.db.models.deletion
|
||||
import smoothschedule.communication.messaging.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messaging', '0003_add_puck_email_template'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomEmailTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(help_text="Unique identifier for referencing in plugins (e.g., 'monthly-newsletter')", max_length=100)),
|
||||
('name', models.CharField(help_text='Human-readable template name', max_length=255)),
|
||||
('description', models.TextField(blank=True, help_text='Description of when/how this template is used')),
|
||||
('subject_template', models.CharField(help_text='Email subject line. Supports all template tags like {{ customer_name }}', max_length=500)),
|
||||
('puck_data', models.JSONField(default=smoothschedule.communication.messaging.models.default_puck_data, help_text='Puck editor JSON data for email body')),
|
||||
('is_active', models.BooleanField(db_index=True, default=True, help_text='Whether this template is active')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='User who created this template', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_custom_email_templates', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
'indexes': [models.Index(fields=['slug', 'is_active'], name='messaging_c_slug_a4d4d2_idx'), models.Index(fields=['created_at'], name='messaging_c_created_ffd80e_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('slug',), name='unique_custom_template_slug_per_tenant')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,10 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .email_types import EmailType
|
||||
from .email_tags import validate_tags, extract_tags_from_puck_data
|
||||
|
||||
|
||||
class CommunicationSession(models.Model):
|
||||
@@ -305,3 +309,290 @@ class MessageRecipient(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.message.subject} -> {self.user.email}"
|
||||
|
||||
|
||||
def default_puck_data():
|
||||
"""Default Puck data structure for email templates."""
|
||||
return {
|
||||
'content': [],
|
||||
'root': {},
|
||||
}
|
||||
|
||||
|
||||
class PuckEmailTemplate(models.Model):
|
||||
"""
|
||||
Puck-based email template for tenant-customizable system emails.
|
||||
|
||||
Each tenant has one template per email type. Templates use Puck JSON
|
||||
for visual editing and support variable tags like {{ customer_name }}.
|
||||
|
||||
Key features:
|
||||
- Tenant-scoped: each business has their own templates
|
||||
- Type-specific: one template per EmailType per tenant
|
||||
- Tag validation: only allowed tags are permitted
|
||||
- Email-safe: Puck components render to email-safe HTML
|
||||
"""
|
||||
|
||||
# Tenant association - templates are tenant-scoped
|
||||
# Note: In multi-tenant setup, this is handled by django-tenants
|
||||
# For explicit tenant tracking (if needed), add FK here
|
||||
|
||||
email_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=EmailType.choices(),
|
||||
db_index=True,
|
||||
help_text="Type of email this template is for"
|
||||
)
|
||||
|
||||
# Subject line with tag support
|
||||
subject_template = models.CharField(
|
||||
max_length=500,
|
||||
help_text="Email subject line. Supports tags like {{ customer_name }}"
|
||||
)
|
||||
|
||||
# Puck visual editor data
|
||||
puck_data = models.JSONField(
|
||||
default=default_puck_data,
|
||||
help_text="Puck editor JSON data for email body"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Whether this template is active"
|
||||
)
|
||||
|
||||
is_customized = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether tenant has customized from default"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_email_templates_puck',
|
||||
help_text="User who created/last edited this template"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'messaging'
|
||||
ordering = ['email_type']
|
||||
# In multi-tenant setup with django-tenants, uniqueness is per-schema
|
||||
# If using explicit tenant FK, add: unique_together = [('tenant', 'email_type')]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['email_type'],
|
||||
name='unique_email_type_per_tenant'
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=['email_type', 'is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
type_display = EmailType.get_display_name(EmailType(self.email_type))
|
||||
status = "Active" if self.is_active else "Inactive"
|
||||
return f"{type_display} ({status})"
|
||||
|
||||
def clean(self):
|
||||
"""Validate template before saving."""
|
||||
super().clean()
|
||||
|
||||
# Validate tags
|
||||
try:
|
||||
email_type_enum = EmailType(self.email_type)
|
||||
except ValueError:
|
||||
raise ValidationError({'email_type': f'Invalid email type: {self.email_type}'})
|
||||
|
||||
errors = validate_tags(
|
||||
self.subject_template,
|
||||
self.puck_data,
|
||||
email_type_enum
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise ValidationError({'subject_template': errors})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Run validation before saving."""
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_email_type_display_name(self) -> str:
|
||||
"""Get human-readable email type name."""
|
||||
try:
|
||||
return EmailType.get_display_name(EmailType(self.email_type))
|
||||
except ValueError:
|
||||
return self.email_type
|
||||
|
||||
def get_email_type_description(self) -> str:
|
||||
"""Get description of when this email is sent."""
|
||||
try:
|
||||
return EmailType.get_description(EmailType(self.email_type))
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
def render(self, context: dict) -> dict:
|
||||
"""
|
||||
Render the template with given context.
|
||||
|
||||
Args:
|
||||
context: Dictionary of template variables
|
||||
|
||||
Returns:
|
||||
Dict with 'subject', 'html', and 'text' keys
|
||||
"""
|
||||
from .email_renderer import render_email
|
||||
return render_email(self, context)
|
||||
|
||||
@classmethod
|
||||
def get_or_create_for_type(cls, email_type: EmailType) -> 'PuckEmailTemplate':
|
||||
"""
|
||||
Get template for email type, creating from default if needed.
|
||||
|
||||
This ensures every email type has a template available.
|
||||
|
||||
Args:
|
||||
email_type: The EmailType enum value
|
||||
|
||||
Returns:
|
||||
PuckEmailTemplate instance
|
||||
"""
|
||||
template, created = cls.objects.get_or_create(
|
||||
email_type=email_type.value,
|
||||
defaults=cls._get_default_for_type(email_type)
|
||||
)
|
||||
|
||||
return template
|
||||
|
||||
@classmethod
|
||||
def _get_default_for_type(cls, email_type: EmailType) -> dict:
|
||||
"""Get default template data for an email type."""
|
||||
from .default_templates import DEFAULT_TEMPLATES
|
||||
from .utils import add_component_ids
|
||||
|
||||
default = DEFAULT_TEMPLATES.get(email_type.value, {})
|
||||
puck_data = default.get('puck_data', default_puck_data())
|
||||
|
||||
# Ensure all components have unique IDs (required by Puck)
|
||||
puck_data_with_ids = add_component_ids(puck_data)
|
||||
|
||||
return {
|
||||
'subject_template': default.get('subject_template', f'{email_type.value} email'),
|
||||
'puck_data': puck_data_with_ids,
|
||||
'is_customized': False,
|
||||
}
|
||||
|
||||
def reset_to_default(self):
|
||||
"""Reset template to system default."""
|
||||
try:
|
||||
email_type_enum = EmailType(self.email_type)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
defaults = self._get_default_for_type(email_type_enum)
|
||||
self.subject_template = defaults['subject_template']
|
||||
self.puck_data = defaults['puck_data']
|
||||
self.is_customized = False
|
||||
self.save()
|
||||
|
||||
|
||||
class CustomEmailTemplate(models.Model):
|
||||
"""
|
||||
Custom email template created by tenants for use with plugins.
|
||||
|
||||
Unlike PuckEmailTemplate (system emails), these are:
|
||||
- User-created with custom names/slugs
|
||||
- Have access to ALL template tags
|
||||
- Can be referenced by plugins via slug
|
||||
- Not tied to a specific system email type
|
||||
"""
|
||||
|
||||
# Unique identifier for plugin reference
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Unique identifier for referencing in plugins (e.g., 'monthly-newsletter')"
|
||||
)
|
||||
|
||||
# Display name
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Human-readable template name"
|
||||
)
|
||||
|
||||
# Description for admin reference
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of when/how this template is used"
|
||||
)
|
||||
|
||||
# Subject line with tag support
|
||||
subject_template = models.CharField(
|
||||
max_length=500,
|
||||
help_text="Email subject line. Supports all template tags like {{ customer_name }}"
|
||||
)
|
||||
|
||||
# Puck visual editor data
|
||||
puck_data = models.JSONField(
|
||||
default=default_puck_data,
|
||||
help_text="Puck editor JSON data for email body"
|
||||
)
|
||||
|
||||
# Status
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Whether this template is active"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_custom_email_templates',
|
||||
help_text="User who created this template"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'messaging'
|
||||
ordering = ['name']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['slug'],
|
||||
name='unique_custom_template_slug_per_tenant'
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=['slug', 'is_active']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
status = "Active" if self.is_active else "Inactive"
|
||||
return f"{self.name} ({status})"
|
||||
|
||||
def render(self, context: dict) -> dict:
|
||||
"""
|
||||
Render the template with given context.
|
||||
|
||||
Args:
|
||||
context: Dictionary of template variables
|
||||
|
||||
Returns:
|
||||
Dict with 'subject', 'html', and 'text' keys
|
||||
"""
|
||||
from .email_renderer import render_custom_email
|
||||
return render_custom_email(self, context)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from .models import BroadcastMessage, MessageRecipient
|
||||
from .models import BroadcastMessage, MessageRecipient, PuckEmailTemplate, CustomEmailTemplate
|
||||
from .email_types import EmailType
|
||||
from .email_tags import validate_tags, get_tag_info_for_email_type
|
||||
|
||||
|
||||
class MessageRecipientSerializer(serializers.ModelSerializer):
|
||||
@@ -280,3 +282,222 @@ class InboxMessageSerializer(serializers.ModelSerializer):
|
||||
if recipient:
|
||||
return recipient.read_at
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Puck Email Template Serializers
|
||||
# =============================================================================
|
||||
|
||||
class PuckEmailTemplateListSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for listing email templates."""
|
||||
display_name = serializers.SerializerMethodField()
|
||||
description = serializers.SerializerMethodField()
|
||||
category = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PuckEmailTemplate
|
||||
fields = [
|
||||
'id', 'email_type', 'display_name', 'description', 'category',
|
||||
'subject_template', 'is_active', 'is_customized',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_display_name(self, obj):
|
||||
return obj.get_email_type_display_name()
|
||||
|
||||
def get_description(self, obj):
|
||||
return obj.get_email_type_description()
|
||||
|
||||
def get_category(self, obj):
|
||||
try:
|
||||
return EmailType.get_category(EmailType(obj.email_type))
|
||||
except ValueError:
|
||||
return 'other'
|
||||
|
||||
|
||||
class PuckEmailTemplateDetailSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for viewing/editing a single email template."""
|
||||
display_name = serializers.SerializerMethodField()
|
||||
description = serializers.SerializerMethodField()
|
||||
category = serializers.SerializerMethodField()
|
||||
available_tags = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = PuckEmailTemplate
|
||||
fields = [
|
||||
'id', 'email_type', 'display_name', 'description', 'category',
|
||||
'subject_template', 'puck_data',
|
||||
'is_active', 'is_customized',
|
||||
'available_tags',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = [
|
||||
'id', 'email_type', 'display_name', 'description', 'category',
|
||||
'available_tags', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
def get_display_name(self, obj):
|
||||
return obj.get_email_type_display_name()
|
||||
|
||||
def get_description(self, obj):
|
||||
return obj.get_email_type_description()
|
||||
|
||||
def get_category(self, obj):
|
||||
try:
|
||||
return EmailType.get_category(EmailType(obj.email_type))
|
||||
except ValueError:
|
||||
return 'other'
|
||||
|
||||
def get_available_tags(self, obj):
|
||||
try:
|
||||
email_type = EmailType(obj.email_type)
|
||||
return get_tag_info_for_email_type(email_type)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate tags in subject and puck_data."""
|
||||
instance = self.instance
|
||||
if not instance:
|
||||
return data
|
||||
|
||||
subject = data.get('subject_template', instance.subject_template)
|
||||
puck_data = data.get('puck_data', instance.puck_data)
|
||||
|
||||
try:
|
||||
email_type = EmailType(instance.email_type)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError({
|
||||
'email_type': f'Invalid email type: {instance.email_type}'
|
||||
})
|
||||
|
||||
errors = validate_tags(subject, puck_data, email_type)
|
||||
if errors:
|
||||
raise serializers.ValidationError({'subject_template': errors})
|
||||
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Mark template as customized when updated."""
|
||||
validated_data['is_customized'] = True
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class EmailTemplatePreviewSerializer(serializers.Serializer):
|
||||
"""Serializer for previewing rendered email template."""
|
||||
context = serializers.DictField(
|
||||
child=serializers.CharField(allow_blank=True),
|
||||
required=False,
|
||||
default=dict,
|
||||
help_text="Tag values for preview rendering"
|
||||
)
|
||||
|
||||
|
||||
class EmailTagSerializer(serializers.Serializer):
|
||||
"""Serializer for tag information."""
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
category = serializers.CharField()
|
||||
syntax = serializers.CharField()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Custom Email Template Serializers (for plugins)
|
||||
# =============================================================================
|
||||
|
||||
class CustomEmailTemplateListSerializer(serializers.ModelSerializer):
|
||||
"""List serializer for custom email templates."""
|
||||
|
||||
class Meta:
|
||||
model = CustomEmailTemplate
|
||||
fields = [
|
||||
'id',
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class CustomEmailTemplateDetailSerializer(serializers.ModelSerializer):
|
||||
"""Detail serializer for custom email templates with all fields."""
|
||||
available_tags = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CustomEmailTemplate
|
||||
fields = [
|
||||
'id',
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'subject_template',
|
||||
'puck_data',
|
||||
'is_active',
|
||||
'available_tags',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'available_tags', 'created_at', 'updated_at']
|
||||
|
||||
def get_available_tags(self, obj):
|
||||
"""Return all available tags for custom templates."""
|
||||
from .email_tags import get_all_tag_info
|
||||
return get_all_tag_info()
|
||||
|
||||
|
||||
class CustomEmailTemplateCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating custom email templates."""
|
||||
|
||||
class Meta:
|
||||
model = CustomEmailTemplate
|
||||
fields = [
|
||||
'slug',
|
||||
'name',
|
||||
'description',
|
||||
'subject_template',
|
||||
'puck_data',
|
||||
'is_active',
|
||||
]
|
||||
|
||||
def validate_slug(self, value):
|
||||
"""Ensure slug is unique within tenant."""
|
||||
if CustomEmailTemplate.objects.filter(slug=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
f"A template with slug '{value}' already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Set created_by to current user."""
|
||||
request = self.context.get('request')
|
||||
if request and request.user:
|
||||
validated_data['created_by'] = request.user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class CustomEmailTemplateUpdateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for updating custom email templates."""
|
||||
|
||||
class Meta:
|
||||
model = CustomEmailTemplate
|
||||
fields = [
|
||||
'name',
|
||||
'description',
|
||||
'subject_template',
|
||||
'puck_data',
|
||||
'is_active',
|
||||
]
|
||||
|
||||
def validate_slug(self, value):
|
||||
"""Ensure slug remains unique if changed."""
|
||||
instance = self.instance
|
||||
if instance and value != instance.slug:
|
||||
if CustomEmailTemplate.objects.filter(slug=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
f"A template with slug '{value}' already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
"""
|
||||
Tests for Puck-based Email Template System
|
||||
|
||||
TDD: These tests are written BEFORE implementation.
|
||||
Tests cover:
|
||||
- EmailType enum completeness
|
||||
- PuckEmailTemplate model CRUD
|
||||
- Tag allowlist validation
|
||||
- Tenant isolation
|
||||
- Email rendering pipeline
|
||||
- API endpoints
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: EmailType Enum
|
||||
# =============================================================================
|
||||
|
||||
class TestEmailTypeEnum:
|
||||
"""Test that the EmailType enum contains all required email types."""
|
||||
|
||||
def test_enum_has_appointment_confirmation(self):
|
||||
"""APPOINTMENT_CONFIRMATION type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'APPOINTMENT_CONFIRMATION')
|
||||
assert EmailType.APPOINTMENT_CONFIRMATION.value == 'appointment_confirmation'
|
||||
|
||||
def test_enum_has_appointment_reminder(self):
|
||||
"""APPOINTMENT_REMINDER type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'APPOINTMENT_REMINDER')
|
||||
assert EmailType.APPOINTMENT_REMINDER.value == 'appointment_reminder'
|
||||
|
||||
def test_enum_has_appointment_rescheduled(self):
|
||||
"""APPOINTMENT_RESCHEDULED type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'APPOINTMENT_RESCHEDULED')
|
||||
assert EmailType.APPOINTMENT_RESCHEDULED.value == 'appointment_rescheduled'
|
||||
|
||||
def test_enum_has_appointment_cancelled(self):
|
||||
"""APPOINTMENT_CANCELLED type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'APPOINTMENT_CANCELLED')
|
||||
assert EmailType.APPOINTMENT_CANCELLED.value == 'appointment_cancelled'
|
||||
|
||||
def test_enum_has_welcome(self):
|
||||
"""WELCOME type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'WELCOME')
|
||||
assert EmailType.WELCOME.value == 'welcome'
|
||||
|
||||
def test_enum_has_thank_you(self):
|
||||
"""THANK_YOU type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'THANK_YOU')
|
||||
assert EmailType.THANK_YOU.value == 'thank_you'
|
||||
|
||||
def test_enum_has_payment_receipt(self):
|
||||
"""PAYMENT_RECEIPT type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'PAYMENT_RECEIPT')
|
||||
assert EmailType.PAYMENT_RECEIPT.value == 'payment_receipt'
|
||||
|
||||
def test_enum_has_contract_signing_request(self):
|
||||
"""CONTRACT_SIGNING_REQUEST type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'CONTRACT_SIGNING_REQUEST')
|
||||
assert EmailType.CONTRACT_SIGNING_REQUEST.value == 'contract_signing_request'
|
||||
|
||||
def test_enum_has_contract_reminder(self):
|
||||
"""CONTRACT_REMINDER type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'CONTRACT_REMINDER')
|
||||
assert EmailType.CONTRACT_REMINDER.value == 'contract_reminder'
|
||||
|
||||
def test_enum_has_contract_signed(self):
|
||||
"""CONTRACT_SIGNED type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'CONTRACT_SIGNED')
|
||||
assert EmailType.CONTRACT_SIGNED.value == 'contract_signed'
|
||||
|
||||
def test_enum_has_ticket_assigned(self):
|
||||
"""TICKET_ASSIGNED type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'TICKET_ASSIGNED')
|
||||
assert EmailType.TICKET_ASSIGNED.value == 'ticket_assigned'
|
||||
|
||||
def test_enum_has_ticket_reply(self):
|
||||
"""TICKET_REPLY type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'TICKET_REPLY')
|
||||
assert EmailType.TICKET_REPLY.value == 'ticket_reply'
|
||||
|
||||
def test_enum_has_ticket_resolved(self):
|
||||
"""TICKET_RESOLVED type exists."""
|
||||
from ..email_types import EmailType
|
||||
assert hasattr(EmailType, 'TICKET_RESOLVED')
|
||||
assert EmailType.TICKET_RESOLVED.value == 'ticket_resolved'
|
||||
|
||||
def test_all_enum_values_are_unique(self):
|
||||
"""All enum values must be unique."""
|
||||
from ..email_types import EmailType
|
||||
values = [e.value for e in EmailType]
|
||||
assert len(values) == len(set(values)), "Duplicate enum values found"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: Tag Allowlist System
|
||||
# =============================================================================
|
||||
|
||||
class TestTagAllowlist:
|
||||
"""Test the tag allowlist validation system."""
|
||||
|
||||
def test_base_tags_are_defined(self):
|
||||
"""Base tags available for all email types are defined."""
|
||||
from ..email_tags import BASE_TAGS
|
||||
|
||||
expected_tags = [
|
||||
'business_name', 'business_email', 'business_phone',
|
||||
'business_address', 'business_logo_url', 'business_website_url',
|
||||
'customer_name', 'customer_first_name', 'customer_last_name',
|
||||
'customer_email', 'customer_phone'
|
||||
]
|
||||
|
||||
for tag in expected_tags:
|
||||
assert tag in BASE_TAGS, f"Missing base tag: {tag}"
|
||||
|
||||
def test_appointment_tags_are_defined(self):
|
||||
"""Appointment-related tags are defined."""
|
||||
from ..email_tags import APPOINTMENT_TAGS
|
||||
|
||||
expected_tags = [
|
||||
'appointment_date', 'appointment_time', 'appointment_datetime',
|
||||
'service_name', 'staff_name', 'location_name',
|
||||
'manage_appointment_link', 'reschedule_link', 'cancel_link',
|
||||
'add_to_calendar_link'
|
||||
]
|
||||
|
||||
for tag in expected_tags:
|
||||
assert tag in APPOINTMENT_TAGS, f"Missing appointment tag: {tag}"
|
||||
|
||||
def test_payment_tags_are_defined(self):
|
||||
"""Payment-related tags are defined."""
|
||||
from ..email_tags import PAYMENT_TAGS
|
||||
|
||||
expected_tags = [
|
||||
'amount_due', 'amount_paid', 'currency',
|
||||
'invoice_number', 'receipt_link'
|
||||
]
|
||||
|
||||
for tag in expected_tags:
|
||||
assert tag in PAYMENT_TAGS, f"Missing payment tag: {tag}"
|
||||
|
||||
def test_get_allowed_tags_for_appointment_confirmation(self):
|
||||
"""APPOINTMENT_CONFIRMATION has base + appointment tags."""
|
||||
from ..email_tags import get_allowed_tags_for_email_type
|
||||
from ..email_types import EmailType
|
||||
|
||||
allowed = get_allowed_tags_for_email_type(EmailType.APPOINTMENT_CONFIRMATION)
|
||||
|
||||
# Should include base tags
|
||||
assert 'customer_name' in allowed
|
||||
assert 'business_name' in allowed
|
||||
|
||||
# Should include appointment tags
|
||||
assert 'appointment_date' in allowed
|
||||
assert 'service_name' in allowed
|
||||
|
||||
# Should NOT include payment tags
|
||||
assert 'amount_paid' not in allowed
|
||||
|
||||
def test_get_allowed_tags_for_payment_receipt(self):
|
||||
"""PAYMENT_RECEIPT has base + payment tags."""
|
||||
from ..email_tags import get_allowed_tags_for_email_type
|
||||
from ..email_types import EmailType
|
||||
|
||||
allowed = get_allowed_tags_for_email_type(EmailType.PAYMENT_RECEIPT)
|
||||
|
||||
# Should include base tags
|
||||
assert 'customer_name' in allowed
|
||||
|
||||
# Should include payment tags
|
||||
assert 'amount_paid' in allowed
|
||||
assert 'receipt_link' in allowed
|
||||
|
||||
def test_validate_tags_accepts_valid_tags(self):
|
||||
"""validate_tags passes for valid tag usage."""
|
||||
from ..email_tags import validate_tags
|
||||
from ..email_types import EmailType
|
||||
|
||||
subject = "Hello {{ customer_name }}"
|
||||
body_content = "Your appointment on {{ appointment_date }}"
|
||||
|
||||
# Should not raise
|
||||
errors = validate_tags(subject, body_content, EmailType.APPOINTMENT_CONFIRMATION)
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_validate_tags_rejects_unknown_tags(self):
|
||||
"""validate_tags fails for unknown tags."""
|
||||
from ..email_tags import validate_tags
|
||||
from ..email_types import EmailType
|
||||
|
||||
subject = "Hello {{ unknown_tag }}"
|
||||
body_content = "Your appointment"
|
||||
|
||||
errors = validate_tags(subject, body_content, EmailType.APPOINTMENT_CONFIRMATION)
|
||||
assert len(errors) > 0
|
||||
assert 'unknown_tag' in str(errors[0])
|
||||
|
||||
def test_validate_tags_rejects_payment_tags_in_appointment_email(self):
|
||||
"""Payment tags not allowed in appointment emails."""
|
||||
from ..email_tags import validate_tags
|
||||
from ..email_types import EmailType
|
||||
|
||||
subject = "Hello"
|
||||
body_content = "Amount paid: {{ amount_paid }}"
|
||||
|
||||
errors = validate_tags(subject, body_content, EmailType.APPOINTMENT_CONFIRMATION)
|
||||
assert len(errors) > 0
|
||||
assert 'amount_paid' in str(errors[0])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: PuckEmailTemplate Model
|
||||
# =============================================================================
|
||||
|
||||
class TestPuckEmailTemplateModel:
|
||||
"""Test PuckEmailTemplate model behavior (unit tests with mocks)."""
|
||||
|
||||
def test_model_has_required_fields(self):
|
||||
"""Model has all required fields."""
|
||||
from ..models import PuckEmailTemplate
|
||||
|
||||
# Check field existence
|
||||
fields = [f.name for f in PuckEmailTemplate._meta.get_fields()]
|
||||
|
||||
assert 'email_type' in fields
|
||||
assert 'subject_template' in fields
|
||||
assert 'puck_data' in fields
|
||||
assert 'is_active' in fields
|
||||
assert 'created_at' in fields
|
||||
assert 'updated_at' in fields
|
||||
|
||||
def test_unique_constraint_tenant_email_type(self):
|
||||
"""Only one active template per tenant per email_type."""
|
||||
from ..models import PuckEmailTemplate
|
||||
|
||||
# Check unique_together constraint
|
||||
unique_together = PuckEmailTemplate._meta.unique_together
|
||||
# Should have (tenant, email_type) as unique constraint
|
||||
# Note: exact format depends on implementation
|
||||
assert len(unique_together) > 0 or hasattr(PuckEmailTemplate._meta, 'constraints')
|
||||
|
||||
def test_email_type_choices_match_enum(self):
|
||||
"""email_type field uses EmailType enum values."""
|
||||
from ..models import PuckEmailTemplate
|
||||
from ..email_types import EmailType
|
||||
|
||||
field = PuckEmailTemplate._meta.get_field('email_type')
|
||||
choices = dict(field.choices)
|
||||
|
||||
for email_type in EmailType:
|
||||
assert email_type.value in choices, f"Missing choice for {email_type}"
|
||||
|
||||
def test_puck_data_is_json_field(self):
|
||||
"""puck_data is a JSONField."""
|
||||
from ..models import PuckEmailTemplate
|
||||
from django.db.models import JSONField
|
||||
|
||||
field = PuckEmailTemplate._meta.get_field('puck_data')
|
||||
assert isinstance(field, JSONField)
|
||||
|
||||
def test_default_puck_data_structure(self):
|
||||
"""Default puck_data has expected structure."""
|
||||
from ..models import PuckEmailTemplate
|
||||
|
||||
field = PuckEmailTemplate._meta.get_field('puck_data')
|
||||
default = field.default
|
||||
|
||||
if callable(default):
|
||||
default = default()
|
||||
|
||||
# Should have content array
|
||||
assert 'content' in default
|
||||
assert isinstance(default['content'], list)
|
||||
|
||||
def test_clean_validates_tags(self):
|
||||
"""Model clean() method validates tags."""
|
||||
from ..models import PuckEmailTemplate
|
||||
from ..email_types import EmailType
|
||||
|
||||
template = PuckEmailTemplate(
|
||||
email_type=EmailType.APPOINTMENT_CONFIRMATION.value,
|
||||
subject_template="Hello {{ invalid_tag }}",
|
||||
puck_data={'content': []},
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
template.clean()
|
||||
|
||||
assert 'invalid_tag' in str(exc_info.value)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: Email Rendering Pipeline
|
||||
# =============================================================================
|
||||
|
||||
class TestEmailRenderingPipeline:
|
||||
"""Test the email rendering functions."""
|
||||
|
||||
def test_render_subject_substitutes_tags(self):
|
||||
"""Subject rendering substitutes tags correctly."""
|
||||
from ..email_renderer import render_subject
|
||||
|
||||
template = "Hello {{ customer_first_name }}, your appointment at {{ business_name }}"
|
||||
context = {
|
||||
'customer_first_name': 'John',
|
||||
'business_name': 'Acme Salon'
|
||||
}
|
||||
|
||||
result = render_subject(template, context)
|
||||
assert result == "Hello John, your appointment at Acme Salon"
|
||||
|
||||
def test_render_subject_escapes_html(self):
|
||||
"""Subject rendering escapes HTML in substituted values."""
|
||||
from ..email_renderer import render_subject
|
||||
|
||||
template = "Hello {{ customer_name }}"
|
||||
context = {
|
||||
'customer_name': '<script>alert("xss")</script>'
|
||||
}
|
||||
|
||||
result = render_subject(template, context)
|
||||
assert '<script>' not in result
|
||||
assert '<script>' in result or 'alert' not in result
|
||||
|
||||
def test_render_html_produces_email_safe_output(self):
|
||||
"""HTML rendering produces email-safe HTML."""
|
||||
from ..email_renderer import render_email_html
|
||||
|
||||
puck_data = {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {'text': 'Welcome!', 'level': 'h1'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {'content': 'Hello {{ customer_name }}'}
|
||||
}
|
||||
]
|
||||
}
|
||||
context = {'customer_name': 'Jane'}
|
||||
|
||||
result = render_email_html(puck_data, context)
|
||||
|
||||
# Should contain heading
|
||||
assert 'Welcome!' in result
|
||||
# Should substitute tag
|
||||
assert 'Jane' in result
|
||||
# Should be table-based layout (email-safe)
|
||||
assert '<table' in result.lower()
|
||||
# Should NOT contain dangerous elements
|
||||
assert '<script' not in result.lower()
|
||||
assert '<iframe' not in result.lower()
|
||||
|
||||
def test_render_html_applies_inline_styles(self):
|
||||
"""HTML rendering applies inline styles for email clients."""
|
||||
from ..email_renderer import render_email_html
|
||||
|
||||
puck_data = {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {'text': 'Click Me', 'href': 'https://example.com'}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result = render_email_html(puck_data, {})
|
||||
|
||||
# Button should have inline styles
|
||||
assert 'style=' in result
|
||||
# Should be a link (not an actual button element)
|
||||
assert 'href="https://example.com"' in result
|
||||
|
||||
def test_render_plaintext_from_puck_data(self):
|
||||
"""Plain text rendering produces readable text."""
|
||||
from ..email_renderer import render_email_plaintext
|
||||
|
||||
puck_data = {
|
||||
'content': [
|
||||
{
|
||||
'type': 'EmailHeading',
|
||||
'props': {'text': 'Welcome!', 'level': 'h1'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailText',
|
||||
'props': {'content': 'Hello {{ customer_name }}, thanks for booking.'}
|
||||
},
|
||||
{
|
||||
'type': 'EmailButton',
|
||||
'props': {'text': 'View Appointment', 'href': 'https://example.com/apt'}
|
||||
}
|
||||
]
|
||||
}
|
||||
context = {'customer_name': 'Bob'}
|
||||
|
||||
result = render_email_plaintext(puck_data, context)
|
||||
|
||||
# Should contain text content
|
||||
assert 'Welcome!' in result
|
||||
assert 'Bob' in result
|
||||
assert 'thanks for booking' in result
|
||||
# Button should show as link
|
||||
assert 'https://example.com/apt' in result
|
||||
# Should NOT contain HTML
|
||||
assert '<' not in result
|
||||
|
||||
def test_render_email_returns_all_parts(self):
|
||||
"""render_email returns subject, html, and text."""
|
||||
from ..email_renderer import render_email
|
||||
from ..models import PuckEmailTemplate
|
||||
from ..email_types import EmailType
|
||||
|
||||
# Mock template
|
||||
template = Mock(spec=PuckEmailTemplate)
|
||||
template.subject_template = "Appointment on {{ appointment_date }}"
|
||||
template.puck_data = {
|
||||
'content': [
|
||||
{'type': 'EmailText', 'props': {'content': 'Hello {{ customer_name }}'}}
|
||||
]
|
||||
}
|
||||
template.email_type = EmailType.APPOINTMENT_CONFIRMATION.value
|
||||
|
||||
context = {
|
||||
'appointment_date': 'Dec 15',
|
||||
'customer_name': 'Alice'
|
||||
}
|
||||
|
||||
result = render_email(template, context)
|
||||
|
||||
assert 'subject' in result
|
||||
assert 'html' in result
|
||||
assert 'text' in result
|
||||
|
||||
assert result['subject'] == 'Appointment on Dec 15'
|
||||
assert 'Alice' in result['html']
|
||||
assert 'Alice' in result['text']
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: API Endpoints
|
||||
# =============================================================================
|
||||
|
||||
class TestEmailTemplateAPIEndpoints:
|
||||
"""Test API endpoint behavior (unit tests with mocks)."""
|
||||
|
||||
def test_list_templates_returns_all_email_types(self):
|
||||
"""GET /email-templates/ returns template for each email type."""
|
||||
from ..views import EmailTemplateViewSet
|
||||
from ..email_types import EmailType
|
||||
from ..serializers import PuckEmailTemplateListSerializer
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/email-templates/')
|
||||
# Mock user with proper role value
|
||||
mock_user = Mock(is_authenticated=True, role='TENANT_OWNER')
|
||||
request.user = mock_user
|
||||
request.tenant = Mock(id=1)
|
||||
|
||||
view = EmailTemplateViewSet.as_view({'get': 'list'})
|
||||
|
||||
# Mock templates for serializer
|
||||
mock_templates = []
|
||||
for et in EmailType:
|
||||
mock_t = Mock()
|
||||
mock_t.id = 1
|
||||
mock_t.email_type = et.value
|
||||
mock_t.subject_template = f"Subject for {et.value}"
|
||||
mock_t.is_active = True
|
||||
mock_t.is_customized = False
|
||||
mock_t.created_at = '2024-01-01T00:00:00Z'
|
||||
mock_t.updated_at = '2024-01-01T00:00:00Z'
|
||||
mock_t.get_email_type_display_name = Mock(return_value=et.value.replace('_', ' ').title())
|
||||
mock_t.get_email_type_description = Mock(return_value=f"Desc for {et.value}")
|
||||
mock_templates.append(mock_t)
|
||||
|
||||
with patch.object(EmailTemplateViewSet, 'get_queryset', return_value=mock_templates):
|
||||
with patch('smoothschedule.communication.messaging.views.PuckEmailTemplate') as MockModel:
|
||||
MockModel.get_or_create_for_type = Mock()
|
||||
response = view(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_retrieve_template_by_email_type(self):
|
||||
"""GET /email-templates/{email_type}/ returns specific template."""
|
||||
from ..views import EmailTemplateViewSet
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/email-templates/appointment_confirmation/')
|
||||
mock_user = Mock(is_authenticated=True, role='TENANT_OWNER')
|
||||
request.user = mock_user
|
||||
request.tenant = Mock(id=1)
|
||||
|
||||
view = EmailTemplateViewSet.as_view({'get': 'retrieve'})
|
||||
|
||||
# Create a proper mock template
|
||||
mock_template = Mock()
|
||||
mock_template.id = 1
|
||||
mock_template.email_type = 'appointment_confirmation'
|
||||
mock_template.subject_template = 'Test Subject'
|
||||
mock_template.puck_data = {'content': []}
|
||||
mock_template.is_active = True
|
||||
mock_template.is_customized = False
|
||||
mock_template.created_at = '2024-01-01T00:00:00Z'
|
||||
mock_template.updated_at = '2024-01-01T00:00:00Z'
|
||||
mock_template.get_email_type_display_name = Mock(return_value='Appointment Confirmation')
|
||||
mock_template.get_email_type_description = Mock(return_value='Test description')
|
||||
|
||||
with patch.object(EmailTemplateViewSet, 'get_object', return_value=mock_template):
|
||||
response = view(request, email_type='appointment_confirmation')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_template_validates_tags(self):
|
||||
"""PUT /email-templates/{email_type}/ validates tags."""
|
||||
from ..serializers import PuckEmailTemplateDetailSerializer
|
||||
from ..email_types import EmailType
|
||||
from ..models import PuckEmailTemplate
|
||||
|
||||
# Create a mock instance for the serializer
|
||||
mock_instance = Mock(spec=PuckEmailTemplate)
|
||||
mock_instance.email_type = EmailType.APPOINTMENT_CONFIRMATION.value
|
||||
mock_instance.subject_template = 'Old subject'
|
||||
mock_instance.puck_data = {'content': []}
|
||||
|
||||
# Test serializer validation with invalid tag
|
||||
data = {
|
||||
'subject_template': 'Hello {{ invalid_tag }}',
|
||||
'puck_data': {'content': []},
|
||||
}
|
||||
|
||||
serializer = PuckEmailTemplateDetailSerializer(
|
||||
instance=mock_instance,
|
||||
data=data,
|
||||
partial=True
|
||||
)
|
||||
|
||||
# Should be invalid due to tag
|
||||
is_valid = serializer.is_valid()
|
||||
assert not is_valid
|
||||
assert 'subject_template' in serializer.errors
|
||||
|
||||
def test_reset_template_restores_default(self):
|
||||
"""POST /email-templates/{email_type}/reset/ restores default."""
|
||||
from ..views import EmailTemplateViewSet
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/email-templates/appointment_confirmation/reset/')
|
||||
mock_user = Mock(is_authenticated=True, role='TENANT_OWNER')
|
||||
request.user = mock_user
|
||||
request.tenant = Mock(id=1)
|
||||
|
||||
view = EmailTemplateViewSet.as_view({'post': 'reset'})
|
||||
|
||||
# Create a mock template with reset_to_default method
|
||||
mock_template = Mock()
|
||||
mock_template.id = 1
|
||||
mock_template.email_type = 'appointment_confirmation'
|
||||
mock_template.subject_template = 'Reset Subject'
|
||||
mock_template.puck_data = {'content': []}
|
||||
mock_template.is_active = True
|
||||
mock_template.is_customized = False
|
||||
mock_template.created_at = '2024-01-01T00:00:00Z'
|
||||
mock_template.updated_at = '2024-01-01T00:00:00Z'
|
||||
mock_template.get_email_type_display_name = Mock(return_value='Appointment Confirmation')
|
||||
mock_template.get_email_type_description = Mock(return_value='Test description')
|
||||
mock_template.reset_to_default = Mock()
|
||||
|
||||
with patch.object(EmailTemplateViewSet, 'get_object', return_value=mock_template):
|
||||
response = view(request, email_type='appointment_confirmation')
|
||||
|
||||
# Template should be reset
|
||||
assert response.status_code == 200
|
||||
mock_template.reset_to_default.assert_called_once()
|
||||
|
||||
def test_get_tags_endpoint_returns_allowed_tags(self):
|
||||
"""GET /email-template-tags/ returns all email types with their allowed tags."""
|
||||
from ..views import EmailTemplateTagsView
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/email-template-tags/')
|
||||
mock_user = Mock(is_authenticated=True, role='TENANT_OWNER')
|
||||
request.user = mock_user
|
||||
request.tenant = Mock(id=1)
|
||||
|
||||
# ViewSet requires actions mapping
|
||||
view = EmailTemplateTagsView.as_view({'get': 'list'})
|
||||
response = view(request)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.data
|
||||
|
||||
# Should return data for each email type
|
||||
assert 'appointment_confirmation' in data
|
||||
assert 'tags' in data['appointment_confirmation']
|
||||
assert isinstance(data['appointment_confirmation']['tags'], list)
|
||||
|
||||
# Each tag should have name and description
|
||||
for tag in data['appointment_confirmation']['tags']:
|
||||
assert 'name' in tag
|
||||
assert 'description' in tag
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: Tenant Isolation
|
||||
# =============================================================================
|
||||
|
||||
class TestTenantIsolation:
|
||||
"""Test that templates are properly tenant-scoped."""
|
||||
|
||||
def test_queryset_filters_by_tenant(self):
|
||||
"""ViewSet queryset filters templates by current tenant."""
|
||||
from ..views import EmailTemplateViewSet
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/email-templates/')
|
||||
request.user = Mock(is_authenticated=True, role='owner')
|
||||
request.tenant = Mock(id=42, schema_name='tenant_42')
|
||||
|
||||
view = EmailTemplateViewSet()
|
||||
view.request = request
|
||||
view.format_kwarg = None
|
||||
|
||||
# The get_queryset should filter by tenant
|
||||
# This tests the view's logic, not actual DB
|
||||
with patch('smoothschedule.communication.messaging.views.PuckEmailTemplate') as MockModel:
|
||||
mock_manager = MagicMock()
|
||||
MockModel.objects = mock_manager
|
||||
|
||||
view.get_queryset()
|
||||
|
||||
# Verify tenant filter was applied
|
||||
# The exact call depends on implementation
|
||||
|
||||
def test_create_sets_tenant(self):
|
||||
"""Creating template associates it with current tenant via django-tenants."""
|
||||
from ..models import PuckEmailTemplate
|
||||
from ..email_types import EmailType
|
||||
|
||||
# In django-tenants, templates are automatically scoped to the
|
||||
# current tenant schema. This test verifies the model can be created
|
||||
# with valid data (actual tenant scoping is handled by django-tenants).
|
||||
mock_template = Mock(spec=PuckEmailTemplate)
|
||||
mock_template.email_type = EmailType.WELCOME.value
|
||||
mock_template.subject_template = 'Welcome!'
|
||||
mock_template.puck_data = {'content': []}
|
||||
|
||||
# The model should have the expected fields
|
||||
assert mock_template.email_type == 'welcome'
|
||||
assert mock_template.subject_template == 'Welcome!'
|
||||
assert mock_template.puck_data == {'content': []}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: Default Template Seeding
|
||||
# =============================================================================
|
||||
|
||||
class TestDefaultTemplateSeeding:
|
||||
"""Test default template seeding behavior."""
|
||||
|
||||
def test_default_templates_exist_for_all_types(self):
|
||||
"""Default templates are defined for all email types."""
|
||||
from ..default_templates import DEFAULT_TEMPLATES
|
||||
from ..email_types import EmailType
|
||||
|
||||
for email_type in EmailType:
|
||||
assert email_type.value in DEFAULT_TEMPLATES, \
|
||||
f"Missing default template for {email_type.value}"
|
||||
|
||||
def test_default_template_has_required_fields(self):
|
||||
"""Each default template has subject and puck_data."""
|
||||
from ..default_templates import DEFAULT_TEMPLATES
|
||||
|
||||
for email_type, template in DEFAULT_TEMPLATES.items():
|
||||
assert 'subject_template' in template, \
|
||||
f"Missing subject_template for {email_type}"
|
||||
assert 'puck_data' in template, \
|
||||
f"Missing puck_data for {email_type}"
|
||||
assert 'content' in template['puck_data'], \
|
||||
f"Missing content array in puck_data for {email_type}"
|
||||
|
||||
def test_default_template_uses_valid_tags(self):
|
||||
"""Default templates only use valid tags for their type."""
|
||||
from ..default_templates import DEFAULT_TEMPLATES
|
||||
from ..email_tags import validate_tags
|
||||
from ..email_types import EmailType
|
||||
|
||||
for email_type_str, template in DEFAULT_TEMPLATES.items():
|
||||
email_type = EmailType(email_type_str)
|
||||
|
||||
# Extract text content from puck_data for validation
|
||||
subject = template['subject_template']
|
||||
|
||||
# Simple text extraction from puck_data
|
||||
body_text = extract_text_from_puck_data(template['puck_data'])
|
||||
|
||||
errors = validate_tags(subject, body_text, email_type)
|
||||
assert len(errors) == 0, \
|
||||
f"Default template for {email_type_str} has invalid tags: {errors}"
|
||||
|
||||
|
||||
def extract_text_from_puck_data(puck_data: dict) -> str:
|
||||
"""Helper to extract text content from Puck data for tag validation."""
|
||||
texts = []
|
||||
for item in puck_data.get('content', []):
|
||||
props = item.get('props', {})
|
||||
if 'text' in props:
|
||||
texts.append(props['text'])
|
||||
if 'content' in props:
|
||||
texts.append(props['content'])
|
||||
return ' '.join(texts)
|
||||
@@ -1,10 +1,19 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import BroadcastMessageViewSet, InboxViewSet
|
||||
from .views import (
|
||||
BroadcastMessageViewSet,
|
||||
InboxViewSet,
|
||||
EmailTemplateViewSet,
|
||||
EmailTemplateTagsView,
|
||||
CustomEmailTemplateViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'broadcast-messages', BroadcastMessageViewSet, basename='broadcast-message')
|
||||
router.register(r'inbox', InboxViewSet, basename='inbox')
|
||||
router.register(r'email-templates', EmailTemplateViewSet, basename='email-template')
|
||||
router.register(r'email-template-tags', EmailTemplateTagsView, basename='email-template-tags')
|
||||
router.register(r'custom-email-templates', CustomEmailTemplateViewSet, basename='custom-email-template')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Email Template Utilities
|
||||
|
||||
Helper functions for email template processing.
|
||||
"""
|
||||
import uuid
|
||||
import copy
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def add_component_ids(puck_data: Optional[dict]) -> dict:
|
||||
"""
|
||||
Add unique IDs to each component in Puck data.
|
||||
|
||||
Puck requires each component in the content array to have a unique 'id' field.
|
||||
This function generates UUIDs for any components missing IDs.
|
||||
|
||||
Args:
|
||||
puck_data: The Puck data structure with 'content' and 'root' keys
|
||||
|
||||
Returns:
|
||||
A deep copy of puck_data with IDs added to all components,
|
||||
or empty dict if puck_data is None/empty
|
||||
"""
|
||||
if not puck_data:
|
||||
return {'content': [], 'root': {}}
|
||||
|
||||
# Make a deep copy to avoid modifying the original
|
||||
data = copy.deepcopy(puck_data)
|
||||
|
||||
content = data.get('content', [])
|
||||
for item in content:
|
||||
if 'id' not in item:
|
||||
# Generate a readable ID with component type prefix
|
||||
item['id'] = f"{item.get('type', 'component')}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
return data
|
||||
@@ -9,13 +9,22 @@ from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from smoothschedule.identity.users.models import User
|
||||
from .models import BroadcastMessage, MessageRecipient
|
||||
from .models import BroadcastMessage, MessageRecipient, PuckEmailTemplate, CustomEmailTemplate
|
||||
from .email_types import EmailType
|
||||
from .email_tags import get_tag_info_for_email_type, get_all_tag_info
|
||||
from .serializers import (
|
||||
BroadcastMessageListSerializer,
|
||||
BroadcastMessageDetailSerializer,
|
||||
BroadcastMessageCreateSerializer,
|
||||
InboxMessageSerializer,
|
||||
MessageRecipientSerializer
|
||||
MessageRecipientSerializer,
|
||||
PuckEmailTemplateListSerializer,
|
||||
PuckEmailTemplateDetailSerializer,
|
||||
EmailTemplatePreviewSerializer,
|
||||
CustomEmailTemplateListSerializer,
|
||||
CustomEmailTemplateDetailSerializer,
|
||||
CustomEmailTemplateCreateSerializer,
|
||||
CustomEmailTemplateUpdateSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -341,3 +350,425 @@ class InboxViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
message__status=BroadcastMessage.Status.SENT
|
||||
).count()
|
||||
return Response({'count': count})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Email Template Views
|
||||
# =============================================================================
|
||||
|
||||
class IsOwnerOrManager(BasePermission):
|
||||
"""Only owners and managers can manage email templates."""
|
||||
message = "You must be an owner or manager to manage email templates."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
return request.user.role in [
|
||||
User.Role.TENANT_OWNER,
|
||||
User.Role.TENANT_MANAGER,
|
||||
]
|
||||
|
||||
|
||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Puck-based email templates.
|
||||
|
||||
Templates are tenant-scoped (one per email type per tenant).
|
||||
Supports viewing, updating, and resetting templates.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, IsOwnerOrManager]
|
||||
lookup_field = 'email_type'
|
||||
lookup_value_regex = '[a-z_]+'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return all templates for the current tenant."""
|
||||
return PuckEmailTemplate.objects.all()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return PuckEmailTemplateListSerializer
|
||||
return PuckEmailTemplateDetailSerializer
|
||||
|
||||
def get_object(self):
|
||||
"""
|
||||
Get or create template for the specified email_type.
|
||||
|
||||
This ensures every email type has a template available.
|
||||
"""
|
||||
email_type_value = self.kwargs.get(self.lookup_field)
|
||||
|
||||
try:
|
||||
email_type = EmailType(email_type_value)
|
||||
except ValueError:
|
||||
from rest_framework.exceptions import NotFound
|
||||
raise NotFound(f"Invalid email type: {email_type_value}")
|
||||
|
||||
template = PuckEmailTemplate.get_or_create_for_type(email_type)
|
||||
return template
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
List all email templates, creating defaults for missing types.
|
||||
"""
|
||||
# Ensure all email types have templates
|
||||
for email_type in EmailType:
|
||||
PuckEmailTemplate.get_or_create_for_type(email_type)
|
||||
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
Create is not allowed - templates are created automatically.
|
||||
"""
|
||||
return Response(
|
||||
{'error': 'Templates are created automatically. Use PUT to update.'},
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""
|
||||
Delete is not allowed - use reset instead.
|
||||
"""
|
||||
return Response(
|
||||
{'error': 'Templates cannot be deleted. Use POST /{email_type}/reset to restore defaults.'},
|
||||
status=status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reset(self, request, email_type=None):
|
||||
"""
|
||||
Reset a template to its default content.
|
||||
"""
|
||||
template = self.get_object()
|
||||
template.reset_to_default()
|
||||
serializer = self.get_serializer(template)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def preview(self, request, email_type=None):
|
||||
"""
|
||||
Preview rendered email with sample data.
|
||||
|
||||
Accepts optional context dict with tag values for rendering.
|
||||
Returns rendered subject, html, and text.
|
||||
"""
|
||||
template = self.get_object()
|
||||
|
||||
# Parse preview context
|
||||
preview_serializer = EmailTemplatePreviewSerializer(data=request.data)
|
||||
preview_serializer.is_valid(raise_exception=True)
|
||||
context = preview_serializer.validated_data.get('context', {})
|
||||
|
||||
# Add sample values for missing tags
|
||||
sample_context = self._get_sample_context(template.email_type)
|
||||
sample_context.update(context)
|
||||
|
||||
# Check white-label permission from tenant
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
if tenant and tenant.has_feature('can_white_label'):
|
||||
sample_context['can_remove_branding'] = True
|
||||
|
||||
# Render the template
|
||||
rendered = template.render(sample_context)
|
||||
|
||||
return Response({
|
||||
'subject': rendered['subject'],
|
||||
'html': rendered['html'],
|
||||
'text': rendered['text'],
|
||||
})
|
||||
|
||||
def _get_sample_context(self, email_type_value: str) -> dict:
|
||||
"""Generate sample context for preview."""
|
||||
return {
|
||||
# Business info
|
||||
'business_name': 'Acme Business',
|
||||
'business_email': 'contact@acme.com',
|
||||
'business_phone': '(555) 123-4567',
|
||||
'business_address': '123 Main St, City, ST 12345',
|
||||
'business_logo_url': '',
|
||||
'business_website_url': 'https://acme.com',
|
||||
|
||||
# Customer info
|
||||
'customer_name': 'John Doe',
|
||||
'customer_first_name': 'John',
|
||||
'customer_last_name': 'Doe',
|
||||
'customer_email': 'john@example.com',
|
||||
'customer_phone': '(555) 987-6543',
|
||||
|
||||
# Date/time
|
||||
'current_date': 'December 15, 2024',
|
||||
'current_year': '2024',
|
||||
|
||||
# Appointment
|
||||
'appointment_date': 'December 20, 2024',
|
||||
'appointment_time': '2:00 PM',
|
||||
'appointment_datetime': 'December 20, 2024 at 2:00 PM',
|
||||
'appointment_duration': '1 hour',
|
||||
'service_name': 'Consultation',
|
||||
'service_description': 'Initial consultation meeting',
|
||||
'staff_name': 'Jane Smith',
|
||||
'location_name': 'Main Office',
|
||||
'location_address': '123 Main St, Suite 100',
|
||||
'manage_appointment_link': 'https://example.com/manage/123',
|
||||
'reschedule_link': 'https://example.com/reschedule/123',
|
||||
'cancel_link': 'https://example.com/cancel/123',
|
||||
'add_to_calendar_link': 'https://example.com/calendar/123.ics',
|
||||
|
||||
# Contract
|
||||
'contract_title': 'Service Agreement',
|
||||
'contract_description': 'Standard service agreement terms',
|
||||
'signing_link': 'https://example.com/sign/123',
|
||||
'contract_expires_at': 'January 1, 2025',
|
||||
'days_until_expiry': '7',
|
||||
'signed_at': 'December 15, 2024 at 3:00 PM',
|
||||
|
||||
# Payment
|
||||
'amount_due': '$150.00',
|
||||
'amount_paid': '$150.00',
|
||||
'currency': 'USD',
|
||||
'invoice_number': 'INV-2024-001',
|
||||
'receipt_link': 'https://example.com/receipt/123',
|
||||
'payment_link': 'https://example.com/pay/123',
|
||||
'payment_due_date': 'December 31, 2024',
|
||||
|
||||
# Ticket
|
||||
'ticket_id': 'TKT-001',
|
||||
'ticket_subject': 'Help with booking',
|
||||
'ticket_status': 'Open',
|
||||
'ticket_priority': 'Normal',
|
||||
'ticket_message': 'I need help with my booking...',
|
||||
'reply_message': 'Thank you for contacting us...',
|
||||
'ticket_link': 'https://example.com/ticket/123',
|
||||
'assignee_name': 'Support Team',
|
||||
|
||||
# Extra
|
||||
'deposit_amount': '$50.00',
|
||||
'total_paid': '$150.00',
|
||||
'review_link': 'https://example.com/review/123',
|
||||
}
|
||||
|
||||
|
||||
class EmailTemplateTagsView(viewsets.ViewSet):
|
||||
"""
|
||||
API endpoint for retrieving available template tags.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, IsOwnerOrManager]
|
||||
|
||||
def list(self, request):
|
||||
"""
|
||||
List all email types with their available tags.
|
||||
"""
|
||||
result = {}
|
||||
for email_type in EmailType:
|
||||
result[email_type.value] = {
|
||||
'display_name': EmailType.get_display_name(email_type),
|
||||
'description': EmailType.get_description(email_type),
|
||||
'category': EmailType.get_category(email_type),
|
||||
'tags': get_tag_info_for_email_type(email_type),
|
||||
}
|
||||
return Response(result)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def by_type(self, request):
|
||||
"""
|
||||
Get tags for a specific email type.
|
||||
|
||||
Query parameter: email_type
|
||||
"""
|
||||
email_type_value = request.query_params.get('email_type')
|
||||
if not email_type_value:
|
||||
return Response(
|
||||
{'error': 'email_type query parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
email_type = EmailType(email_type_value)
|
||||
except ValueError:
|
||||
return Response(
|
||||
{'error': f'Invalid email type: {email_type_value}'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return Response({
|
||||
'email_type': email_type.value,
|
||||
'display_name': EmailType.get_display_name(email_type),
|
||||
'description': EmailType.get_description(email_type),
|
||||
'category': EmailType.get_category(email_type),
|
||||
'tags': get_tag_info_for_email_type(email_type),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Custom Email Template Views (for plugins)
|
||||
# =============================================================================
|
||||
|
||||
class CustomEmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing custom email templates.
|
||||
|
||||
Custom templates are created by tenants for use with plugins.
|
||||
They have access to ALL template tags (not restricted by email type).
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, IsOwnerOrManager]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return all custom templates for the current tenant."""
|
||||
return CustomEmailTemplate.objects.all()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return CustomEmailTemplateListSerializer
|
||||
elif self.action == 'create':
|
||||
return CustomEmailTemplateCreateSerializer
|
||||
elif self.action in ['update', 'partial_update']:
|
||||
return CustomEmailTemplateUpdateSerializer
|
||||
return CustomEmailTemplateDetailSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set created_by to current user."""
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def preview(self, request, pk=None):
|
||||
"""
|
||||
Preview rendered email with sample data.
|
||||
|
||||
Accepts optional context dict with tag values for rendering.
|
||||
Returns rendered subject, html, and text.
|
||||
"""
|
||||
template = self.get_object()
|
||||
|
||||
# Parse preview context
|
||||
preview_serializer = EmailTemplatePreviewSerializer(data=request.data)
|
||||
preview_serializer.is_valid(raise_exception=True)
|
||||
context = preview_serializer.validated_data.get('context', {})
|
||||
|
||||
# Add sample values for missing tags
|
||||
sample_context = self._get_sample_context()
|
||||
sample_context.update(context)
|
||||
|
||||
# Check white-label permission from tenant
|
||||
tenant = getattr(request, 'tenant', None)
|
||||
if tenant and tenant.has_feature('can_white_label'):
|
||||
sample_context['can_remove_branding'] = True
|
||||
|
||||
# Render the template
|
||||
rendered = template.render(sample_context)
|
||||
|
||||
return Response({
|
||||
'subject': rendered['subject'],
|
||||
'html': rendered['html'],
|
||||
'text': rendered['text'],
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""
|
||||
Create a copy of an existing template.
|
||||
"""
|
||||
original = self.get_object()
|
||||
|
||||
# Generate unique slug
|
||||
base_slug = f"{original.slug}-copy"
|
||||
slug = base_slug
|
||||
counter = 1
|
||||
while CustomEmailTemplate.objects.filter(slug=slug).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
# Create the duplicate
|
||||
duplicate = CustomEmailTemplate.objects.create(
|
||||
slug=slug,
|
||||
name=f"{original.name} (Copy)",
|
||||
description=original.description,
|
||||
subject_template=original.subject_template,
|
||||
puck_data=original.puck_data,
|
||||
is_active=False, # Start as inactive
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
serializer = CustomEmailTemplateDetailSerializer(duplicate)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def available_tags(self, request):
|
||||
"""
|
||||
Get all available template tags for custom templates.
|
||||
|
||||
Custom templates have access to ALL tags since they may be
|
||||
triggered from any context (plugins, etc).
|
||||
"""
|
||||
return Response({
|
||||
'tags': get_all_tag_info()
|
||||
})
|
||||
|
||||
def _get_sample_context(self) -> dict:
|
||||
"""Generate sample context for preview with all possible tags."""
|
||||
return {
|
||||
# Business info
|
||||
'business_name': 'Acme Business',
|
||||
'business_email': 'contact@acme.com',
|
||||
'business_phone': '(555) 123-4567',
|
||||
'business_address': '123 Main St, City, ST 12345',
|
||||
'business_logo_url': '',
|
||||
'business_website_url': 'https://acme.com',
|
||||
|
||||
# Customer info
|
||||
'customer_name': 'John Doe',
|
||||
'customer_first_name': 'John',
|
||||
'customer_last_name': 'Doe',
|
||||
'customer_email': 'john@example.com',
|
||||
'customer_phone': '(555) 987-6543',
|
||||
|
||||
# Date/time
|
||||
'current_date': 'December 15, 2024',
|
||||
'current_year': '2024',
|
||||
|
||||
# Appointment
|
||||
'appointment_date': 'December 20, 2024',
|
||||
'appointment_time': '2:00 PM',
|
||||
'appointment_datetime': 'December 20, 2024 at 2:00 PM',
|
||||
'appointment_duration': '1 hour',
|
||||
'service_name': 'Consultation',
|
||||
'service_description': 'Initial consultation meeting',
|
||||
'staff_name': 'Jane Smith',
|
||||
'location_name': 'Main Office',
|
||||
'location_address': '123 Main St, Suite 100',
|
||||
'manage_appointment_link': 'https://example.com/manage/123',
|
||||
'reschedule_link': 'https://example.com/reschedule/123',
|
||||
'cancel_link': 'https://example.com/cancel/123',
|
||||
'add_to_calendar_link': 'https://example.com/calendar/123.ics',
|
||||
|
||||
# Contract
|
||||
'contract_title': 'Service Agreement',
|
||||
'contract_description': 'Standard service agreement terms',
|
||||
'signing_link': 'https://example.com/sign/123',
|
||||
'contract_expires_at': 'January 1, 2025',
|
||||
'days_until_expiry': '7',
|
||||
'signed_at': 'December 15, 2024 at 3:00 PM',
|
||||
|
||||
# Payment
|
||||
'amount_due': '$150.00',
|
||||
'amount_paid': '$150.00',
|
||||
'currency': 'USD',
|
||||
'invoice_number': 'INV-2024-001',
|
||||
'receipt_link': 'https://example.com/receipt/123',
|
||||
'payment_link': 'https://example.com/pay/123',
|
||||
'payment_due_date': 'December 31, 2024',
|
||||
|
||||
# Ticket
|
||||
'ticket_id': 'TKT-001',
|
||||
'ticket_subject': 'Help with booking',
|
||||
'ticket_status': 'Open',
|
||||
'ticket_priority': 'Normal',
|
||||
'ticket_message': 'I need help with my booking...',
|
||||
'reply_message': 'Thank you for contacting us...',
|
||||
'ticket_link': 'https://example.com/ticket/123',
|
||||
'assignee_name': 'Support Team',
|
||||
|
||||
# Extra
|
||||
'deposit_amount': '$50.00',
|
||||
'total_paid': '$150.00',
|
||||
'review_link': 'https://example.com/review/123',
|
||||
}
|
||||
|
||||
@@ -52,11 +52,8 @@ class QuotaService:
|
||||
'display_name': 'services',
|
||||
'count_method': 'count_services',
|
||||
},
|
||||
'MAX_EMAIL_TEMPLATES': {
|
||||
'model': 'schedule.models.EmailTemplate',
|
||||
'display_name': 'email templates',
|
||||
'count_method': 'count_email_templates',
|
||||
},
|
||||
# Note: MAX_EMAIL_TEMPLATES quota removed - email templates are now system-wide
|
||||
# using PuckEmailTemplate in the messaging app, not per-tenant
|
||||
'MAX_AUTOMATED_TASKS': {
|
||||
'model': 'schedule.models.ScheduledTask',
|
||||
'display_name': 'automated tasks',
|
||||
@@ -90,10 +87,7 @@ class QuotaService:
|
||||
from smoothschedule.scheduling.schedule.models import Service
|
||||
return Service.objects.filter(is_archived_by_quota=False).count()
|
||||
|
||||
def count_email_templates(self) -> int:
|
||||
"""Count email templates."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
return EmailTemplate.objects.count()
|
||||
# Note: count_email_templates removed - templates are now system-wide via PuckEmailTemplate
|
||||
|
||||
def count_automated_tasks(self) -> int:
|
||||
"""Count automated tasks."""
|
||||
@@ -120,7 +114,6 @@ class QuotaService:
|
||||
'MAX_ADDITIONAL_USERS': 'max_users',
|
||||
'MAX_RESOURCES': 'max_resources',
|
||||
'MAX_SERVICES': 'max_services',
|
||||
'MAX_EMAIL_TEMPLATES': 'max_email_templates',
|
||||
'MAX_AUTOMATED_TASKS': 'max_automated_tasks',
|
||||
}
|
||||
feature_code = feature_code_map.get(quota_type, quota_type.lower())
|
||||
|
||||
@@ -118,3 +118,65 @@ def create_site_on_tenant_create(sender, instance, created, **kwargs):
|
||||
|
||||
tenant_id = instance.id
|
||||
transaction.on_commit(lambda: _create_site_for_tenant(tenant_id))
|
||||
|
||||
|
||||
def _seed_email_templates_for_tenant(tenant_schema_name):
|
||||
"""
|
||||
Create default email templates for a tenant.
|
||||
Called after transaction commits to ensure schema tables exist.
|
||||
"""
|
||||
from django_tenants.utils import schema_context
|
||||
from smoothschedule.communication.messaging.models import PuckEmailTemplate
|
||||
from smoothschedule.communication.messaging.email_types import EmailType
|
||||
from smoothschedule.communication.messaging.default_templates import DEFAULT_TEMPLATES
|
||||
|
||||
logger.info(f"Seeding email templates for new tenant: {tenant_schema_name}")
|
||||
|
||||
try:
|
||||
with schema_context(tenant_schema_name):
|
||||
created_count = 0
|
||||
|
||||
for email_type in EmailType:
|
||||
# Check if template already exists
|
||||
if PuckEmailTemplate.objects.filter(email_type=email_type.value).exists():
|
||||
continue
|
||||
|
||||
# Get default template data
|
||||
default_data = DEFAULT_TEMPLATES.get(email_type.value, {})
|
||||
if not default_data:
|
||||
logger.warning(f"No default template found for {email_type.value}")
|
||||
continue
|
||||
|
||||
# Create the template
|
||||
PuckEmailTemplate.objects.create(
|
||||
email_type=email_type.value,
|
||||
subject_template=default_data.get('subject_template', ''),
|
||||
puck_data=default_data.get('puck_data', {'content': [], 'root': {}}),
|
||||
is_active=True,
|
||||
is_customized=False,
|
||||
)
|
||||
created_count += 1
|
||||
|
||||
logger.info(f"Created {created_count} email templates for tenant: {tenant_schema_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed email templates for tenant {tenant_schema_name}: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender='core.Tenant')
|
||||
def seed_email_templates_on_tenant_create(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Seed default email templates when a new tenant is created.
|
||||
|
||||
This ensures new tenants have all system email templates ready to use.
|
||||
Uses transaction.on_commit() to defer seeding until after the schema is
|
||||
fully created and migrations have run.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
# Skip public schema
|
||||
if instance.schema_name == 'public':
|
||||
return
|
||||
|
||||
schema_name = instance.schema_name
|
||||
transaction.on_commit(lambda: _seed_email_templates_for_tenant(schema_name))
|
||||
|
||||
@@ -34,12 +34,15 @@ class TestQuotaServiceInit:
|
||||
assert QuotaService.GRACE_PERIOD_DAYS == 30
|
||||
|
||||
def test_quota_config_structure(self):
|
||||
"""Should have properly configured quota types."""
|
||||
"""Should have properly configured quota types.
|
||||
|
||||
Note: MAX_EMAIL_TEMPLATES removed - email templates are now
|
||||
system-wide via PuckEmailTemplate in the messaging app.
|
||||
"""
|
||||
expected_types = [
|
||||
'MAX_ADDITIONAL_USERS',
|
||||
'MAX_RESOURCES',
|
||||
'MAX_SERVICES',
|
||||
'MAX_EMAIL_TEMPLATES',
|
||||
'MAX_AUTOMATED_TASKS',
|
||||
]
|
||||
|
||||
@@ -107,19 +110,8 @@ class TestQuotaServiceCountingMethods:
|
||||
)
|
||||
assert count == 7
|
||||
|
||||
def test_count_email_templates(self):
|
||||
"""Should count all email templates."""
|
||||
with patch('smoothschedule.scheduling.schedule.models.EmailTemplate') as mock_template_model:
|
||||
mock_queryset = Mock()
|
||||
mock_queryset.count.return_value = 3
|
||||
|
||||
mock_template_model.objects = mock_queryset
|
||||
|
||||
mock_tenant = Mock(id=1)
|
||||
service = QuotaService(tenant=mock_tenant)
|
||||
count = service.count_email_templates()
|
||||
|
||||
assert count == 3
|
||||
# Note: test_count_email_templates removed - email templates are now system-wide
|
||||
# using PuckEmailTemplate in the messaging app, not per-tenant quotas
|
||||
|
||||
def test_count_automated_tasks(self):
|
||||
"""Should count all automated tasks."""
|
||||
@@ -343,7 +335,11 @@ class TestQuotaServiceCheckAllQuotas:
|
||||
"""Test check_all_quotas method."""
|
||||
|
||||
def test_check_all_quotas_checks_all_types(self):
|
||||
"""Should check all configured quota types."""
|
||||
"""Should check all configured quota types.
|
||||
|
||||
Note: MAX_EMAIL_TEMPLATES removed - email templates are now
|
||||
system-wide via PuckEmailTemplate in the messaging app.
|
||||
"""
|
||||
mock_tenant = Mock(id=1)
|
||||
service = QuotaService(tenant=mock_tenant)
|
||||
|
||||
@@ -352,13 +348,12 @@ class TestQuotaServiceCheckAllQuotas:
|
||||
|
||||
result = service.check_all_quotas()
|
||||
|
||||
# Should check all quota types
|
||||
assert service.check_quota.call_count == 5
|
||||
# Should check all quota types (now 4 instead of 5)
|
||||
assert service.check_quota.call_count == 4
|
||||
quota_types_checked = [call[0][0] for call in service.check_quota.call_args_list]
|
||||
assert 'MAX_ADDITIONAL_USERS' in quota_types_checked
|
||||
assert 'MAX_RESOURCES' in quota_types_checked
|
||||
assert 'MAX_SERVICES' in quota_types_checked
|
||||
assert 'MAX_EMAIL_TEMPLATES' in quota_types_checked
|
||||
assert 'MAX_AUTOMATED_TASKS' in quota_types_checked
|
||||
|
||||
assert result == []
|
||||
|
||||
@@ -1693,6 +1693,9 @@ class TestSignupView:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert 'already exists' in response.data['detail']
|
||||
|
||||
@patch('smoothschedule.billing.models.Plan')
|
||||
@patch('smoothschedule.billing.models.Subscription')
|
||||
@patch('smoothschedule.billing.models.PlanVersion')
|
||||
@patch('smoothschedule.identity.users.api_views.schema_context')
|
||||
@patch('smoothschedule.identity.users.api_views.Tenant')
|
||||
@patch('smoothschedule.identity.users.api_views.Domain')
|
||||
@@ -1702,7 +1705,8 @@ class TestSignupView:
|
||||
def test_creates_tenant_and_owner_successfully(self, mock_get_user_data,
|
||||
mock_token_model, mock_user_model,
|
||||
mock_domain_model, mock_tenant_model,
|
||||
mock_schema_context):
|
||||
mock_schema_context, mock_plan_version,
|
||||
mock_subscription, mock_plan_model):
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/auth/signup/', {
|
||||
'subdomain': 'newbiz',
|
||||
@@ -1718,6 +1722,18 @@ class TestSignupView:
|
||||
mock_tenant_model.objects.filter.return_value.exists.return_value = False
|
||||
mock_user_model.objects.filter.return_value.exists.return_value = False
|
||||
|
||||
# Mock Plan lookup
|
||||
mock_plan = Mock()
|
||||
mock_plan.code = 'starter'
|
||||
mock_plan.id = 1
|
||||
mock_plan_model.objects.get.return_value = mock_plan
|
||||
mock_plan_model.DoesNotExist = Exception
|
||||
|
||||
# Mock PlanVersion
|
||||
mock_version = Mock()
|
||||
mock_version.id = 1
|
||||
mock_plan_version.objects.filter.return_value.order_by.return_value.first.return_value = mock_version
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.id = 1
|
||||
mock_tenant.name = 'New Business'
|
||||
@@ -1744,6 +1760,9 @@ class TestSignupView:
|
||||
mock_domain_model.objects.create.assert_called_once()
|
||||
mock_user_model.objects.create_user.assert_called_once()
|
||||
|
||||
@patch('smoothschedule.billing.models.Plan')
|
||||
@patch('smoothschedule.billing.models.Subscription')
|
||||
@patch('smoothschedule.billing.models.PlanVersion')
|
||||
@patch('smoothschedule.identity.users.api_views.schema_context')
|
||||
@patch('smoothschedule.identity.users.api_views.Tenant')
|
||||
@patch('smoothschedule.identity.users.api_views.Domain')
|
||||
@@ -1753,7 +1772,13 @@ class TestSignupView:
|
||||
def test_applies_tier_permissions_correctly(self, mock_get_user_data,
|
||||
mock_token_model, mock_user_model,
|
||||
mock_domain_model, mock_tenant_model,
|
||||
mock_schema_context):
|
||||
mock_schema_context, mock_plan_version,
|
||||
mock_subscription, mock_plan_model):
|
||||
"""Test that signup creates a subscription with the selected plan.
|
||||
|
||||
Note: Tier permissions are now managed via the billing system (Plan/PlanVersion),
|
||||
not as direct attributes on the Tenant model.
|
||||
"""
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/auth/signup/', {
|
||||
'subdomain': 'professional',
|
||||
@@ -1767,7 +1792,19 @@ class TestSignupView:
|
||||
mock_tenant_model.objects.filter.return_value.exists.return_value = False
|
||||
mock_user_model.objects.filter.return_value.exists.return_value = False
|
||||
|
||||
# Mock Plan lookup with versions
|
||||
mock_version = Mock()
|
||||
mock_version.id = 1
|
||||
|
||||
mock_plan = Mock()
|
||||
mock_plan.code = 'professional'
|
||||
mock_plan.id = 1
|
||||
mock_plan.versions.filter.return_value.order_by.return_value.first.return_value = mock_version
|
||||
mock_plan_model.objects.get.return_value = mock_plan
|
||||
mock_plan_model.DoesNotExist = Exception
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.id = 1
|
||||
mock_tenant_model.objects.create.return_value = mock_tenant
|
||||
|
||||
mock_user = Mock()
|
||||
@@ -1782,12 +1819,14 @@ class TestSignupView:
|
||||
|
||||
response = api_views.signup_view(request)
|
||||
|
||||
# Verify PROFESSIONAL tier permissions were applied
|
||||
create_call = mock_tenant_model.objects.create.call_args
|
||||
assert create_call[1]['can_accept_payments'] is True
|
||||
assert create_call[1]['can_use_custom_domain'] is True
|
||||
assert create_call[1]['can_api_access'] is True
|
||||
assert create_call[1]['can_white_label'] is False # Not in professional
|
||||
# Verify the response is successful
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify a subscription was created with the professional plan
|
||||
mock_subscription.objects.create.assert_called_once()
|
||||
create_call = mock_subscription.objects.create.call_args
|
||||
assert create_call[1]['business'] == mock_tenant
|
||||
assert create_call[1]['plan_version'] == mock_version
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -496,19 +496,17 @@ class TestTenantUpdateSerializer:
|
||||
|
||||
assert 'id' in serializer.Meta.read_only_fields
|
||||
|
||||
def test_includes_permission_fields(self):
|
||||
"""Verify permission fields are included."""
|
||||
def test_includes_expected_fields(self):
|
||||
"""Verify expected fields are included.
|
||||
|
||||
Note: Permission fields are now managed via billing system,
|
||||
not directly on the Tenant model.
|
||||
"""
|
||||
serializer = TenantUpdateSerializer()
|
||||
|
||||
permission_fields = [
|
||||
'can_manage_oauth_credentials',
|
||||
'can_accept_payments',
|
||||
'can_use_custom_domain',
|
||||
'can_white_label',
|
||||
'can_api_access',
|
||||
]
|
||||
expected_fields = ['id', 'name', 'is_active', 'contact_email', 'phone']
|
||||
|
||||
for field in permission_fields:
|
||||
for field in expected_fields:
|
||||
assert field in serializer.Meta.fields
|
||||
|
||||
def test_update_saves_in_public_schema(self):
|
||||
@@ -519,7 +517,7 @@ class TestTenantUpdateSerializer:
|
||||
validated_data = {
|
||||
'name': 'Updated Name',
|
||||
'is_active': False,
|
||||
'max_users': 10,
|
||||
'contact_email': 'test@example.com',
|
||||
}
|
||||
|
||||
serializer = TenantUpdateSerializer()
|
||||
@@ -531,7 +529,7 @@ class TestTenantUpdateSerializer:
|
||||
# Verify attributes were set
|
||||
assert mock_instance.name == 'Updated Name'
|
||||
assert mock_instance.is_active is False
|
||||
assert mock_instance.max_users == 10
|
||||
assert mock_instance.contact_email == 'test@example.com'
|
||||
|
||||
# Verify schema_context was called with 'public'
|
||||
mock_schema_context.assert_called_once_with('public')
|
||||
@@ -551,14 +549,14 @@ class TestTenantCreateSerializer:
|
||||
assert not serializer.fields['subdomain'].required or serializer.fields['subdomain'].required
|
||||
|
||||
def test_optional_fields_have_defaults(self):
|
||||
"""Verify optional fields have sensible defaults."""
|
||||
"""Verify optional fields have sensible defaults.
|
||||
|
||||
Note: subscription_tier, max_users, max_resources, etc. are now
|
||||
managed via the billing system, not serializer fields.
|
||||
"""
|
||||
serializer = TenantCreateSerializer()
|
||||
|
||||
assert serializer.fields['subscription_tier'].default == 'FREE'
|
||||
assert serializer.fields['is_active'].default is True
|
||||
assert serializer.fields['max_users'].default == 5
|
||||
assert serializer.fields['max_resources'].default == 10
|
||||
assert serializer.fields['can_manage_oauth_credentials'].default is False
|
||||
|
||||
def test_owner_password_is_write_only(self):
|
||||
"""Verify owner_password is write-only."""
|
||||
|
||||
@@ -934,11 +934,16 @@ class TenantViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
# Get or create subscription for this tenant
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
now = timezone.now()
|
||||
subscription, created = Subscription.objects.get_or_create(
|
||||
business=tenant,
|
||||
defaults={
|
||||
'plan_version': active_version,
|
||||
'status': 'active',
|
||||
'current_period_start': now,
|
||||
'current_period_end': now + timedelta(days=30),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-1829
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
# Generated manually - Remove deprecated EmailTemplate model
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
Remove the deprecated EmailTemplate model.
|
||||
|
||||
The old EmailTemplate model is being replaced by the new PuckEmailTemplate
|
||||
model in the communication.messaging app, which provides a visual email
|
||||
builder using Puck.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0038_album_mediafile_album_cover_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='EmailTemplate',
|
||||
),
|
||||
]
|
||||
@@ -1530,146 +1530,6 @@ class PluginInstallation(models.Model):
|
||||
self.save()
|
||||
|
||||
|
||||
class EmailTemplate(models.Model):
|
||||
"""
|
||||
Reusable email template for plugins and automations.
|
||||
|
||||
Supports both text and HTML content with template variable substitution.
|
||||
Business templates are tenant-specific, Platform templates are shared/system-wide.
|
||||
"""
|
||||
|
||||
class Scope(models.TextChoices):
|
||||
BUSINESS = 'BUSINESS', 'Business' # Tenant-specific
|
||||
PLATFORM = 'PLATFORM', 'Platform' # Platform-wide (shared)
|
||||
|
||||
class Category(models.TextChoices):
|
||||
APPOINTMENT = 'APPOINTMENT', 'Appointment'
|
||||
REMINDER = 'REMINDER', 'Reminder'
|
||||
CONFIRMATION = 'CONFIRMATION', 'Confirmation'
|
||||
MARKETING = 'MARKETING', 'Marketing'
|
||||
NOTIFICATION = 'NOTIFICATION', 'Notification'
|
||||
REPORT = 'REPORT', 'Report'
|
||||
OTHER = 'OTHER', 'Other'
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Email structure
|
||||
subject = models.CharField(
|
||||
max_length=500,
|
||||
help_text="Email subject line - supports template variables like {{CUSTOMER_NAME}}"
|
||||
)
|
||||
html_content = models.TextField(
|
||||
blank=True,
|
||||
help_text="HTML email body"
|
||||
)
|
||||
text_content = models.TextField(
|
||||
blank=True,
|
||||
help_text="Plain text email body (fallback for non-HTML clients)"
|
||||
)
|
||||
|
||||
# Scope
|
||||
scope = models.CharField(
|
||||
max_length=20,
|
||||
choices=Scope.choices,
|
||||
default=Scope.BUSINESS,
|
||||
)
|
||||
|
||||
# Only for PLATFORM scope templates
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Default template for certain system triggers"
|
||||
)
|
||||
|
||||
# Category for organization
|
||||
category = models.CharField(
|
||||
max_length=50,
|
||||
choices=Category.choices,
|
||||
default=Category.OTHER,
|
||||
)
|
||||
|
||||
# Preview data for visual preview
|
||||
preview_context = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Sample data for rendering preview"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_email_templates'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'schedule'
|
||||
ordering = ['name']
|
||||
indexes = [
|
||||
models.Index(fields=['scope', 'category']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_scope_display()})"
|
||||
|
||||
def render(self, context: dict, force_footer: bool = False):
|
||||
"""
|
||||
Render the template with given context.
|
||||
|
||||
Args:
|
||||
context: Dictionary of template variables
|
||||
force_footer: If True, append "Powered by Smooth Schedule" footer
|
||||
|
||||
Returns:
|
||||
Tuple of (subject, html_content, text_content)
|
||||
"""
|
||||
from .template_parser import TemplateVariableParser
|
||||
|
||||
subject = TemplateVariableParser.replace_insertion_codes(
|
||||
self.subject, context
|
||||
)
|
||||
html = TemplateVariableParser.replace_insertion_codes(
|
||||
self.html_content, context
|
||||
) if self.html_content else ''
|
||||
text = TemplateVariableParser.replace_insertion_codes(
|
||||
self.text_content, context
|
||||
) if self.text_content else ''
|
||||
|
||||
# Append footer for free tier if applicable
|
||||
if force_footer:
|
||||
html = self._append_html_footer(html)
|
||||
text = self._append_text_footer(text)
|
||||
|
||||
return subject, html, text
|
||||
|
||||
def _append_html_footer(self, html: str) -> str:
|
||||
"""Append Powered by Smooth Schedule footer to HTML"""
|
||||
import re
|
||||
footer = '''
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #9ca3af; font-size: 12px;">
|
||||
<p>
|
||||
Powered by
|
||||
<a href="https://smoothschedule.com" style="color: #6366f1; text-decoration: none; font-weight: 500;">
|
||||
SmoothSchedule
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
'''
|
||||
# Insert before </body> if present, otherwise append
|
||||
if '</body>' in html.lower():
|
||||
return re.sub(r'(</body>)', footer + r'\1', html, flags=re.IGNORECASE)
|
||||
return html + footer
|
||||
|
||||
def _append_text_footer(self, text: str) -> str:
|
||||
"""Append Powered by Smooth Schedule footer to plain text"""
|
||||
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
|
||||
return text + footer
|
||||
|
||||
|
||||
class Album(models.Model):
|
||||
"""
|
||||
Album for organizing media files.
|
||||
|
||||
@@ -1977,94 +1977,151 @@ class SafeScriptAPI:
|
||||
raise ScriptExecutionError(f"Failed to create video meeting: {e}")
|
||||
|
||||
# =========================================================================
|
||||
# EMAIL TEMPLATE METHODS
|
||||
# SYSTEM EMAIL TEMPLATE METHODS
|
||||
# Plugins can send emails using system-level templates configured in
|
||||
# Business Settings > Email Templates.
|
||||
# =========================================================================
|
||||
|
||||
def get_system_email_types(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get available system email types that can be sent.
|
||||
|
||||
Returns a list of email types that have templates configured,
|
||||
such as appointment confirmations, reminders, etc.
|
||||
|
||||
Returns:
|
||||
List of dictionaries with email type info:
|
||||
[
|
||||
{
|
||||
'type': 'appointment_confirmation',
|
||||
'display_name': 'Appointment Confirmation',
|
||||
'description': 'Sent when appointment is booked',
|
||||
'category': 'appointment'
|
||||
},
|
||||
...
|
||||
]
|
||||
"""
|
||||
self._check_api_limit()
|
||||
|
||||
try:
|
||||
from smoothschedule.communication.messaging.email_types import (
|
||||
EmailType, EMAIL_TYPE_INFO
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'type': email_type.value,
|
||||
'display_name': info['display_name'],
|
||||
'description': info['description'],
|
||||
'category': info['category'],
|
||||
}
|
||||
for email_type, info in EMAIL_TYPE_INFO.items()
|
||||
]
|
||||
except ImportError:
|
||||
logger.warning("Email types not available")
|
||||
return []
|
||||
|
||||
def send_system_email(
|
||||
self,
|
||||
email_type: str,
|
||||
to: str,
|
||||
context: Dict[str, str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email using a system template.
|
||||
|
||||
Args:
|
||||
email_type: Type of email to send (e.g., 'appointment_confirmation')
|
||||
Use get_system_email_types() to see available types.
|
||||
to: Recipient email address
|
||||
context: Dictionary of context variables for template rendering.
|
||||
Common variables include:
|
||||
- CUSTOMER_NAME, CUSTOMER_EMAIL
|
||||
- BUSINESS_NAME, BUSINESS_EMAIL, BUSINESS_PHONE
|
||||
- APPOINTMENT_DATE, APPOINTMENT_TIME
|
||||
- SERVICE_NAME, STAFF_NAME
|
||||
Check template's allowed tags for full list.
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
|
||||
Example:
|
||||
api.send_system_email(
|
||||
email_type='appointment_confirmation',
|
||||
to='customer@example.com',
|
||||
context={
|
||||
'CUSTOMER_NAME': 'John Doe',
|
||||
'APPOINTMENT_DATE': 'January 15, 2025',
|
||||
'APPOINTMENT_TIME': '2:00 PM',
|
||||
'SERVICE_NAME': 'Consultation',
|
||||
}
|
||||
)
|
||||
"""
|
||||
self._check_api_limit()
|
||||
|
||||
try:
|
||||
from smoothschedule.communication.messaging.models import PuckEmailTemplate
|
||||
from smoothschedule.communication.messaging.email_renderer import render_email_template
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.conf import settings
|
||||
|
||||
# Find the template for this email type
|
||||
template = PuckEmailTemplate.objects.filter(
|
||||
email_type=email_type,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
logger.warning(f"No active template found for email type: {email_type}")
|
||||
return False
|
||||
|
||||
# Merge provided context with business context
|
||||
full_context = self._get_insertion_context()
|
||||
if context:
|
||||
full_context.update(context)
|
||||
|
||||
# Render the email
|
||||
rendered = render_email_template(template, full_context)
|
||||
|
||||
# Send the email
|
||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=rendered['subject'],
|
||||
body=rendered['text'],
|
||||
from_email=from_email,
|
||||
to=[to],
|
||||
)
|
||||
|
||||
if rendered['html']:
|
||||
msg.attach_alternative(rendered['html'], 'text/html')
|
||||
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
logger.info(f"[Customer Script] System email '{email_type}' sent to {to}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send system email: {e}")
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# EMAIL TEMPLATE METHODS (DEPRECATED - use system email methods above)
|
||||
# =========================================================================
|
||||
|
||||
def get_email_templates(self, **filters) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get email templates for this business with comprehensive filtering.
|
||||
|
||||
Supported filters:
|
||||
- id: Exact template ID
|
||||
- template_type: Filter by type (reminder, confirmation, follow_up, etc.)
|
||||
- template_type__in: Multiple types ['reminder', 'confirmation']
|
||||
- name__icontains: Name contains text (case-insensitive)
|
||||
- subject__icontains: Subject contains text
|
||||
- is_active: Filter by active status (default: True)
|
||||
- created_at__gte, created_at__lte: Filter by creation date
|
||||
- limit: Maximum results (default: 50, max: 100)
|
||||
DEPRECATED: Custom email templates are no longer supported.
|
||||
Use get_system_email_types() instead.
|
||||
|
||||
Returns:
|
||||
List of email template dictionaries
|
||||
|
||||
Requires: can_use_email_templates feature
|
||||
Empty list (custom templates no longer available)
|
||||
"""
|
||||
self._check_api_limit()
|
||||
self._check_feature('can_use_email_templates', 'Email templates')
|
||||
|
||||
try:
|
||||
from smoothschedule.communication.messaging.models import EmailTemplate
|
||||
from django.utils import timezone
|
||||
from dateutil.parser import parse as parse_datetime
|
||||
|
||||
def parse_dt(value):
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
dt = parse_datetime(value)
|
||||
return dt if timezone.is_aware(dt) else timezone.make_aware(dt)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return value
|
||||
|
||||
queryset = EmailTemplate.objects.all()
|
||||
|
||||
# ID filter
|
||||
if 'id' in filters:
|
||||
queryset = queryset.filter(id=filters['id'])
|
||||
|
||||
# Type filters
|
||||
if 'template_type' in filters:
|
||||
queryset = queryset.filter(template_type=filters['template_type'])
|
||||
if 'template_type__in' in filters:
|
||||
queryset = queryset.filter(template_type__in=filters['template_type__in'])
|
||||
|
||||
# Text search filters
|
||||
if 'name__icontains' in filters:
|
||||
queryset = queryset.filter(name__icontains=filters['name__icontains'])
|
||||
if 'subject__icontains' in filters:
|
||||
queryset = queryset.filter(subject__icontains=filters['subject__icontains'])
|
||||
|
||||
# Boolean filters
|
||||
if 'is_active' in filters:
|
||||
queryset = queryset.filter(is_active=filters['is_active'])
|
||||
elif filters.get('is_active', True): # Default to True
|
||||
queryset = queryset.filter(is_active=True)
|
||||
|
||||
# DateTime filters
|
||||
for op in ['__gte', '__lte', '__gt', '__lt']:
|
||||
key = f'created_at{op}'
|
||||
if key in filters:
|
||||
dt = parse_dt(filters[key])
|
||||
if dt:
|
||||
queryset = queryset.filter(**{key: dt})
|
||||
|
||||
limit = min(filters.get('limit', 50), 100)
|
||||
queryset = queryset[:limit]
|
||||
|
||||
return [
|
||||
{
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'template_type': t.template_type,
|
||||
'subject': t.subject,
|
||||
'is_active': t.is_active,
|
||||
'created_at': t.created_at.isoformat(),
|
||||
}
|
||||
for t in queryset
|
||||
]
|
||||
except ImportError:
|
||||
logger.warning("EmailTemplate model not available")
|
||||
return []
|
||||
logger.warning(
|
||||
"[Customer Script] get_email_templates() is deprecated. "
|
||||
"Use get_system_email_types() instead."
|
||||
)
|
||||
return []
|
||||
|
||||
def send_template_email(
|
||||
self,
|
||||
@@ -2073,57 +2130,17 @@ class SafeScriptAPI:
|
||||
variables: Dict[str, str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send an email using a pre-defined template.
|
||||
|
||||
Args:
|
||||
template_id: ID of the email template
|
||||
to: Recipient email address
|
||||
variables: Dictionary of variables to substitute in template
|
||||
DEPRECATED: Custom email templates are no longer supported.
|
||||
Use send_system_email() instead.
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
|
||||
Requires: can_use_email_templates feature
|
||||
False (custom templates no longer available)
|
||||
"""
|
||||
self._check_api_limit()
|
||||
self._check_feature('can_use_email_templates', 'Email templates')
|
||||
|
||||
try:
|
||||
from smoothschedule.communication.messaging.models import EmailTemplate
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
|
||||
template = EmailTemplate.objects.get(id=template_id)
|
||||
|
||||
# Process variables
|
||||
subject = template.subject
|
||||
body = template.body_text or template.body_html
|
||||
|
||||
if variables:
|
||||
for key, value in variables.items():
|
||||
subject = subject.replace(f'{{{key}}}', str(value))
|
||||
body = body.replace(f'{{{key}}}', str(value))
|
||||
|
||||
# Add context from business
|
||||
context = self._get_insertion_context()
|
||||
for key, value in context.items():
|
||||
subject = subject.replace(f'{{{key}}}', str(value))
|
||||
body = body.replace(f'{{{key}}}', str(value))
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[to],
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"[Customer Script] Template email sent to {to}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send template email: {e}")
|
||||
return False
|
||||
logger.warning(
|
||||
"[Customer Script] send_template_email() is deprecated. "
|
||||
"Use send_system_email() instead."
|
||||
)
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# ANALYTICS METHODS
|
||||
|
||||
@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
|
||||
from rest_framework import serializers
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock, Location, Album, MediaFile
|
||||
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, Holiday, TimeBlock, Location, Album, MediaFile
|
||||
from .services import AvailabilityService
|
||||
from smoothschedule.identity.users.models import User
|
||||
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
|
||||
@@ -1269,67 +1269,6 @@ class GlobalEventPluginSerializer(serializers.ModelSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class EmailTemplateSerializer(serializers.ModelSerializer):
|
||||
"""Full serializer for EmailTemplate CRUD operations"""
|
||||
|
||||
created_by_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = EmailTemplate
|
||||
fields = [
|
||||
'id', 'name', 'description', 'subject',
|
||||
'html_content', 'text_content', 'scope',
|
||||
'is_default', 'category', 'preview_context',
|
||||
'created_by', 'created_by_name',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at', 'created_by', 'created_by_name']
|
||||
|
||||
def get_created_by_name(self, obj):
|
||||
"""Get the name of the user who created the template"""
|
||||
if obj.created_by:
|
||||
return obj.created_by.full_name or obj.created_by.username
|
||||
return None
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate template content"""
|
||||
html = attrs.get('html_content', '')
|
||||
text = attrs.get('text_content', '')
|
||||
|
||||
# At least one content type is required
|
||||
if not html and not text:
|
||||
raise serializers.ValidationError(
|
||||
"At least HTML or text content is required"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Set created_by from request context"""
|
||||
request = self.context.get('request')
|
||||
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||||
validated_data['created_by'] = request.user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class EmailTemplateListSerializer(serializers.ModelSerializer):
|
||||
"""Lightweight serializer for email template dropdowns and listings"""
|
||||
|
||||
class Meta:
|
||||
model = EmailTemplate
|
||||
fields = ['id', 'name', 'description', 'category', 'scope', 'updated_at']
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class EmailTemplatePreviewSerializer(serializers.Serializer):
|
||||
"""Serializer for email template preview endpoint"""
|
||||
|
||||
subject = serializers.CharField()
|
||||
html_content = serializers.CharField(allow_blank=True, required=False, default='')
|
||||
text_content = serializers.CharField(allow_blank=True, required=False, default='')
|
||||
context = serializers.DictField(required=False, default=dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Time Blocking System Serializers
|
||||
# =============================================================================
|
||||
|
||||
@@ -1201,111 +1201,6 @@ class TestPluginInstallationModel:
|
||||
installation.save.assert_called_once()
|
||||
|
||||
|
||||
class TestEmailTemplateModel:
|
||||
"""Test EmailTemplate model methods."""
|
||||
|
||||
def test_str_representation(self):
|
||||
"""Test EmailTemplate __str__ method."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
template = Mock(spec=EmailTemplate)
|
||||
template.name = 'Welcome Email'
|
||||
template.get_scope_display = Mock(return_value='Business')
|
||||
|
||||
result = EmailTemplate.__str__(template)
|
||||
assert result == "Welcome Email (Business)"
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.template_parser.TemplateVariableParser')
|
||||
def test_render_replaces_variables(self, mock_parser):
|
||||
"""Test render replaces template variables in content."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
mock_parser.replace_insertion_codes.side_effect = lambda text, ctx: text.replace('{{NAME}}', ctx.get('NAME', ''))
|
||||
|
||||
template = EmailTemplate(
|
||||
subject='Hello {{NAME}}',
|
||||
html_content='<p>Welcome {{NAME}}</p>',
|
||||
text_content='Welcome {{NAME}}'
|
||||
)
|
||||
|
||||
context = {'NAME': 'John'}
|
||||
subject, html, text = template.render(context)
|
||||
|
||||
assert 'John' in subject
|
||||
assert 'John' in html
|
||||
assert 'John' in text
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.template_parser.TemplateVariableParser')
|
||||
def test_render_handles_empty_html(self, mock_parser):
|
||||
"""Test render handles missing HTML content."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
mock_parser.replace_insertion_codes.return_value = 'Test'
|
||||
|
||||
template = EmailTemplate(
|
||||
subject='Test',
|
||||
text_content='Test'
|
||||
)
|
||||
|
||||
subject, html, text = template.render({})
|
||||
assert html == ''
|
||||
|
||||
@patch('smoothschedule.scheduling.schedule.template_parser.TemplateVariableParser')
|
||||
def test_render_adds_footer_when_forced(self, mock_parser):
|
||||
"""Test render appends footer when force_footer is True."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
mock_parser.replace_insertion_codes.side_effect = lambda text, ctx: text
|
||||
|
||||
template = EmailTemplate(
|
||||
subject='Test',
|
||||
html_content='<body>Content</body>',
|
||||
text_content='Content'
|
||||
)
|
||||
|
||||
subject, html, text = template.render({}, force_footer=True)
|
||||
|
||||
assert 'SmoothSchedule' in html
|
||||
assert 'SmoothSchedule' in text
|
||||
|
||||
def test_append_html_footer_inserts_before_body_tag(self):
|
||||
"""Test _append_html_footer inserts before closing body tag."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
template = EmailTemplate()
|
||||
html = '<html><body><p>Content</p></body></html>'
|
||||
|
||||
result = template._append_html_footer(html)
|
||||
|
||||
assert 'SmoothSchedule' in result
|
||||
assert result.index('SmoothSchedule') < result.index('</body>')
|
||||
|
||||
def test_append_html_footer_appends_when_no_body_tag(self):
|
||||
"""Test _append_html_footer appends when no body tag."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
template = EmailTemplate()
|
||||
html = '<p>Content</p>'
|
||||
|
||||
result = template._append_html_footer(html)
|
||||
|
||||
assert 'SmoothSchedule' in result
|
||||
assert '</div>' in result
|
||||
assert result.startswith('<p>Content</p>')
|
||||
|
||||
def test_append_text_footer(self):
|
||||
"""Test _append_text_footer appends text footer."""
|
||||
from smoothschedule.scheduling.schedule.models import EmailTemplate
|
||||
|
||||
template = EmailTemplate()
|
||||
text = 'Email content'
|
||||
|
||||
result = template._append_text_footer(text)
|
||||
|
||||
assert 'SmoothSchedule' in result
|
||||
assert result.startswith('Email content')
|
||||
|
||||
|
||||
class TestHolidayModel:
|
||||
"""Test Holiday model methods."""
|
||||
|
||||
|
||||
@@ -1231,8 +1231,65 @@ class TestSafeScriptAPICreateVideoMeeting:
|
||||
assert result['title'] == 'Test Meeting'
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetSystemEmailTypes:
|
||||
"""Tests for SafeScriptAPI.get_system_email_types method."""
|
||||
|
||||
def test_has_get_system_email_types_method(self):
|
||||
"""Should have get_system_email_types method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'get_system_email_types')
|
||||
assert callable(api.get_system_email_types)
|
||||
|
||||
def test_returns_list_of_email_types(self):
|
||||
"""Should return list of available email types."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
result = api.get_system_email_types()
|
||||
|
||||
assert isinstance(result, list)
|
||||
# Should have at least some email types
|
||||
assert len(result) > 0
|
||||
# Each item should have required fields
|
||||
for item in result:
|
||||
assert 'type' in item
|
||||
assert 'display_name' in item
|
||||
assert 'description' in item
|
||||
assert 'category' in item
|
||||
|
||||
|
||||
class TestSafeScriptAPISendSystemEmail:
|
||||
"""Tests for SafeScriptAPI.send_system_email method."""
|
||||
|
||||
def test_has_send_system_email_method(self):
|
||||
"""Should have send_system_email method."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
assert hasattr(api, 'send_system_email')
|
||||
assert callable(api.send_system_email)
|
||||
|
||||
def test_returns_false_when_template_not_found(self):
|
||||
"""Should return False when no template exists for email type."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
|
||||
# Use a mock to simulate no template found
|
||||
with patch('smoothschedule.communication.messaging.models.PuckEmailTemplate') as mock_model:
|
||||
mock_model.objects.filter.return_value.first.return_value = None
|
||||
|
||||
result = api.send_system_email(
|
||||
email_type='nonexistent_type',
|
||||
to='test@example.com'
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetEmailTemplates:
|
||||
"""Tests for SafeScriptAPI.get_email_templates method."""
|
||||
"""Tests for SafeScriptAPI.get_email_templates method (DEPRECATED)."""
|
||||
|
||||
def test_has_get_email_templates_method(self):
|
||||
"""Should have get_email_templates method."""
|
||||
@@ -1242,23 +1299,17 @@ class TestSafeScriptAPIGetEmailTemplates:
|
||||
assert hasattr(api, 'get_email_templates')
|
||||
assert callable(api.get_email_templates)
|
||||
|
||||
def test_requires_email_templates_feature(self):
|
||||
"""Should require can_use_email_templates feature."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
|
||||
def test_returns_empty_list_deprecated(self):
|
||||
"""Deprecated method should return empty list."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
mock_business = Mock()
|
||||
mock_business.has_feature = Mock(return_value=False)
|
||||
|
||||
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
|
||||
|
||||
with pytest.raises(ScriptExecutionError) as exc_info:
|
||||
api.get_email_templates()
|
||||
|
||||
assert 'not available on your plan' in str(exc_info.value)
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
result = api.get_email_templates()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestSafeScriptAPISendTemplateEmail:
|
||||
"""Tests for SafeScriptAPI.send_template_email method."""
|
||||
"""Tests for SafeScriptAPI.send_template_email method (DEPRECATED)."""
|
||||
|
||||
def test_has_send_template_email_method(self):
|
||||
"""Should have send_template_email method."""
|
||||
@@ -1268,6 +1319,14 @@ class TestSafeScriptAPISendTemplateEmail:
|
||||
assert hasattr(api, 'send_template_email')
|
||||
assert callable(api.send_template_email)
|
||||
|
||||
def test_returns_false_deprecated(self):
|
||||
"""Deprecated method should return False."""
|
||||
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
|
||||
|
||||
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
|
||||
result = api.send_template_email(template_id=1, to='test@example.com')
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestSafeScriptAPIGetAnalytics:
|
||||
"""Tests for SafeScriptAPI.get_analytics method."""
|
||||
|
||||
@@ -20,7 +20,6 @@ from smoothschedule.scheduling.schedule.serializers import (
|
||||
TimeBlockSerializer,
|
||||
HolidaySerializer,
|
||||
PluginInstallationSerializer,
|
||||
EmailTemplateSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -1040,118 +1039,6 @@ class TestPluginInstallationSerializer:
|
||||
mock_installation.has_update_available.assert_called_once()
|
||||
|
||||
|
||||
class TestEmailTemplateSerializer:
|
||||
"""Test EmailTemplateSerializer."""
|
||||
|
||||
def test_read_only_fields(self):
|
||||
"""Test that correct fields are read-only."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
|
||||
assert serializer.fields['created_at'].read_only
|
||||
assert serializer.fields['updated_at'].read_only
|
||||
assert serializer.fields['created_by'].read_only
|
||||
assert serializer.fields['created_by_name'].read_only
|
||||
|
||||
def test_writable_fields(self):
|
||||
"""Test that correct fields are writable."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
writable = [f for f in serializer.fields if not serializer.fields[f].read_only]
|
||||
|
||||
assert 'name' in writable
|
||||
assert 'description' in writable
|
||||
assert 'subject' in writable
|
||||
assert 'html_content' in writable
|
||||
assert 'text_content' in writable
|
||||
assert 'scope' in writable
|
||||
assert 'is_default' in writable
|
||||
assert 'category' in writable
|
||||
assert 'preview_context' in writable
|
||||
|
||||
def test_get_created_by_name_with_full_name(self):
|
||||
"""Test created_by_name with full name."""
|
||||
mock_user = Mock()
|
||||
mock_user.full_name = "Sarah Wilson"
|
||||
mock_user.username = "swilson"
|
||||
|
||||
mock_template = Mock()
|
||||
mock_template.created_by = mock_user
|
||||
|
||||
serializer = EmailTemplateSerializer()
|
||||
name = serializer.get_created_by_name(mock_template)
|
||||
|
||||
assert name == "Sarah Wilson"
|
||||
|
||||
def test_get_created_by_name_falls_back_to_username(self):
|
||||
"""Test created_by_name falls back to username."""
|
||||
mock_user = Mock()
|
||||
mock_user.full_name = None
|
||||
mock_user.username = "tsmith"
|
||||
|
||||
mock_template = Mock()
|
||||
mock_template.created_by = mock_user
|
||||
|
||||
serializer = EmailTemplateSerializer()
|
||||
name = serializer.get_created_by_name(mock_template)
|
||||
|
||||
assert name == "tsmith"
|
||||
|
||||
def test_get_created_by_name_without_user(self):
|
||||
"""Test created_by_name when no user."""
|
||||
mock_template = Mock()
|
||||
mock_template.created_by = None
|
||||
|
||||
serializer = EmailTemplateSerializer()
|
||||
name = serializer.get_created_by_name(mock_template)
|
||||
|
||||
assert name is None
|
||||
|
||||
def test_validate_rejects_empty_content(self):
|
||||
"""Test validation rejects templates with no content."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
attrs = {
|
||||
'html_content': '',
|
||||
'text_content': ''
|
||||
}
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
serializer.validate(attrs)
|
||||
|
||||
assert 'content' in str(exc_info.value).lower()
|
||||
|
||||
def test_validate_allows_html_only(self):
|
||||
"""Test validation allows HTML-only templates."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
attrs = {
|
||||
'html_content': '<p>Hello</p>',
|
||||
'text_content': ''
|
||||
}
|
||||
|
||||
result = serializer.validate(attrs)
|
||||
assert result == attrs
|
||||
|
||||
def test_validate_allows_text_only(self):
|
||||
"""Test validation allows text-only templates."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
attrs = {
|
||||
'html_content': '',
|
||||
'text_content': 'Hello there'
|
||||
}
|
||||
|
||||
result = serializer.validate(attrs)
|
||||
assert result == attrs
|
||||
|
||||
def test_validate_allows_both_content_types(self):
|
||||
"""Test validation allows both HTML and text."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
attrs = {
|
||||
'html_content': '<p>Hello</p>',
|
||||
'text_content': 'Hello there'
|
||||
}
|
||||
|
||||
result = serializer.validate(attrs)
|
||||
assert result == attrs
|
||||
|
||||
|
||||
class TestScheduledTaskSerializer:
|
||||
"""Test ScheduledTaskSerializer."""
|
||||
|
||||
@@ -1861,24 +1748,6 @@ class TestPluginInstallationSerializerFields:
|
||||
assert serializer.fields['id'].read_only
|
||||
|
||||
|
||||
class TestEmailTemplateSerializerFields:
|
||||
"""Test EmailTemplateSerializer fields."""
|
||||
|
||||
def test_has_expected_fields(self):
|
||||
"""Test serializer has expected fields."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
|
||||
assert 'id' in serializer.fields
|
||||
assert 'name' in serializer.fields
|
||||
assert 'subject' in serializer.fields
|
||||
assert 'html_content' in serializer.fields
|
||||
|
||||
def test_id_is_read_only(self):
|
||||
"""Test id field is read-only."""
|
||||
serializer = EmailTemplateSerializer()
|
||||
assert serializer.fields['id'].read_only
|
||||
|
||||
|
||||
class TestCustomerSerializerCreate:
|
||||
"""Test CustomerSerializer create method."""
|
||||
|
||||
|
||||
@@ -843,146 +843,6 @@ class TestHolidayViewSetDates:
|
||||
assert response.data['year'] == date.today().year
|
||||
|
||||
|
||||
class TestEmailTemplateViewSetPreview:
|
||||
"""Test EmailTemplateViewSet.preview action."""
|
||||
|
||||
def test_preview_renders_template_variables(self):
|
||||
"""Test that preview renders template with variables."""
|
||||
from smoothschedule.scheduling.schedule.views import EmailTemplateViewSet
|
||||
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/email-templates/preview/', {
|
||||
'subject': 'Hello {{CUSTOMER_NAME}}',
|
||||
'html_content': '<p>Your appointment is on {{APPOINTMENT_DATE}}</p>',
|
||||
'text_content': 'Your appointment is on {{APPOINTMENT_DATE}}'
|
||||
}, format='json')
|
||||
# Manually set data attribute to simulate DRF Request
|
||||
request.data = {
|
||||
'subject': 'Hello {{CUSTOMER_NAME}}',
|
||||
'html_content': '<p>Your appointment is on {{APPOINTMENT_DATE}}</p>',
|
||||
'text_content': 'Your appointment is on {{APPOINTMENT_DATE}}'
|
||||
}
|
||||
mock_user = Mock()
|
||||
mock_user.is_platform_user = False
|
||||
request.user = mock_user
|
||||
|
||||
viewset = EmailTemplateViewSet()
|
||||
viewset.request = request
|
||||
viewset.format_kwarg = None
|
||||
|
||||
# Mock TemplateVariableParser - it's imported locally in the method
|
||||
# Define replacement function
|
||||
def replace_codes(template, context):
|
||||
result = template
|
||||
result = result.replace('{{CUSTOMER_NAME}}', 'John Doe')
|
||||
result = result.replace('{{APPOINTMENT_DATE}}', 'January 15, 2025')
|
||||
return result
|
||||
|
||||
with patch('smoothschedule.scheduling.schedule.template_parser.TemplateVariableParser') as mock_parser_class:
|
||||
# Set replace_insertion_codes as a static/class method on the mock class
|
||||
mock_parser_class.replace_insertion_codes = replace_codes
|
||||
|
||||
# Mock the connection to avoid subscription tier check (imported locally in function)
|
||||
with patch('django.db.connection') as mock_connection:
|
||||
# Make connection.tenant have a subscription_tier that's not FREE
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.subscription_tier = 'PREMIUM'
|
||||
mock_connection.tenant = mock_tenant
|
||||
|
||||
# Act
|
||||
response = viewset.preview(request)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert 'John Doe' in response.data['subject']
|
||||
assert 'January 15, 2025' in response.data['html_content']
|
||||
|
||||
|
||||
class TestEmailTemplateViewSetDuplicate:
|
||||
"""Test EmailTemplateViewSet.duplicate action."""
|
||||
|
||||
def test_duplicate_creates_copy_with_modified_name(self):
|
||||
"""Test that duplicate creates a copy with (Copy) appended."""
|
||||
from smoothschedule.scheduling.schedule.views import EmailTemplateViewSet
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/email-templates/1/duplicate/', {}, format='json')
|
||||
mock_user = Mock(id=1)
|
||||
request.user = mock_user
|
||||
|
||||
viewset = EmailTemplateViewSet()
|
||||
viewset.request = request
|
||||
viewset.format_kwarg = None
|
||||
|
||||
mock_template = Mock()
|
||||
mock_template.name = 'Test Template'
|
||||
mock_template.description = 'Test Description'
|
||||
mock_template.subject = 'Test Subject'
|
||||
mock_template.html_content = '<p>Test</p>'
|
||||
mock_template.text_content = 'Test'
|
||||
mock_template.scope = 'BUSINESS'
|
||||
mock_template.category = 'APPOINTMENT'
|
||||
mock_template.preview_context = {}
|
||||
|
||||
with patch.object(viewset, 'get_object', return_value=mock_template):
|
||||
with patch('smoothschedule.scheduling.schedule.views.EmailTemplate') as mock_model:
|
||||
from datetime import datetime
|
||||
|
||||
# Create a proper mock with real datetime for created_at
|
||||
mock_new_template = Mock(
|
||||
id=2,
|
||||
name='Test Template (Copy)',
|
||||
created_at=datetime(2025, 1, 1, 12, 0, 0),
|
||||
created_by=None,
|
||||
spec=['id', 'name', 'created_at', 'created_by', 'description', 'subject', 'html_content', 'text_content', 'scope', 'category']
|
||||
)
|
||||
mock_model.objects.create.return_value = mock_new_template
|
||||
|
||||
# Mock the serializer to return a simple dict
|
||||
with patch.object(viewset, 'get_serializer') as mock_get_serializer:
|
||||
# Create a mock serializer with .data as a plain dict
|
||||
mock_serializer = Mock()
|
||||
mock_serializer.data = {'id': 2, 'name': 'Test Template (Copy)'}
|
||||
mock_get_serializer.return_value = mock_serializer
|
||||
|
||||
# Act
|
||||
response = viewset.duplicate(request, pk=1)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 201
|
||||
mock_model.objects.create.assert_called_once()
|
||||
create_kwargs = mock_model.objects.create.call_args[1]
|
||||
assert create_kwargs['name'] == 'Test Template (Copy)'
|
||||
|
||||
|
||||
class TestEmailTemplateViewSetPerformCreate:
|
||||
"""Test EmailTemplateViewSet.perform_create method."""
|
||||
|
||||
def test_perform_create_sets_created_by(self):
|
||||
"""Test that perform_create sets created_by from request user."""
|
||||
from smoothschedule.scheduling.schedule.views import EmailTemplateViewSet
|
||||
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
request = factory.post('/api/email-templates/', {})
|
||||
mock_user = Mock(id=1)
|
||||
request.user = mock_user
|
||||
|
||||
viewset = EmailTemplateViewSet()
|
||||
viewset.request = request
|
||||
|
||||
mock_serializer = Mock()
|
||||
|
||||
# Act
|
||||
viewset.perform_create(mock_serializer)
|
||||
|
||||
# Assert
|
||||
mock_serializer.save.assert_called_once_with(created_by=mock_user)
|
||||
|
||||
|
||||
class TestScheduledTaskViewSetPause:
|
||||
"""Test ScheduledTaskViewSet.pause action."""
|
||||
|
||||
@@ -2100,86 +1960,6 @@ class TestEventViewSetAllowedTransitionsSuccess:
|
||||
assert len(response.data['allowed_transitions']) == 2
|
||||
|
||||
|
||||
class TestEmailTemplateViewSetVariables:
|
||||
"""Test EmailTemplateViewSet.variables action."""
|
||||
|
||||
def test_variables_returns_available_variables(self):
|
||||
"""Test that variables returns all template variables."""
|
||||
from smoothschedule.scheduling.schedule.views import EmailTemplateViewSet
|
||||
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
request = factory.get('/api/email-templates/variables/')
|
||||
request.user = Mock()
|
||||
|
||||
viewset = EmailTemplateViewSet()
|
||||
viewset.request = request
|
||||
viewset.format_kwarg = None
|
||||
|
||||
response = viewset.variables(request)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert 'variables' in response.data
|
||||
assert 'categories' in response.data
|
||||
# Should have Business, Customer, Appointment, Date/Time categories
|
||||
categories = [v['category'] for v in response.data['variables']]
|
||||
assert 'Business' in categories
|
||||
assert 'Customer' in categories
|
||||
|
||||
|
||||
class TestEmailTemplateViewSetPresets:
|
||||
"""Test EmailTemplateViewSet.presets action."""
|
||||
|
||||
def test_presets_returns_presets(self):
|
||||
"""Test that presets returns template presets."""
|
||||
from smoothschedule.scheduling.schedule.views import EmailTemplateViewSet
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
django_request = factory.get('/api/email-templates/presets/')
|
||||
request = Request(django_request)
|
||||
request.user = Mock()
|
||||
|
||||
viewset = EmailTemplateViewSet()
|
||||
viewset.request = request
|
||||
viewset.format_kwarg = None
|
||||
|
||||
# Patch at source since it's a local import
|
||||
with patch('smoothschedule.scheduling.schedule.email_template_presets.get_all_presets') as mock_get_all:
|
||||
mock_get_all.return_value = {'APPOINTMENT': []}
|
||||
response = viewset.presets(request)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert 'presets' in response.data
|
||||
|
||||
def test_presets_filters_by_category(self):
|
||||
"""Test that presets can filter by category."""
|
||||
from smoothschedule.scheduling.schedule.views import EmailTemplateViewSet
|
||||
from rest_framework.request import Request
|
||||
|
||||
# Arrange
|
||||
factory = APIRequestFactory()
|
||||
django_request = factory.get('/api/email-templates/presets/?category=appointment')
|
||||
request = Request(django_request)
|
||||
request.user = Mock()
|
||||
|
||||
viewset = EmailTemplateViewSet()
|
||||
viewset.request = request
|
||||
viewset.format_kwarg = None
|
||||
|
||||
# Patch at source since it's a local import
|
||||
with patch('smoothschedule.scheduling.schedule.email_template_presets.get_presets_by_category') as mock_get_by_cat:
|
||||
mock_get_by_cat.return_value = []
|
||||
response = viewset.presets(request)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_get_by_cat.assert_called_once_with('APPOINTMENT')
|
||||
|
||||
|
||||
class TestEventViewSetGetStaffAssignedEvents:
|
||||
"""Test EventViewSet._get_staff_assigned_events method."""
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from .views import (
|
||||
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
|
||||
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
|
||||
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
|
||||
GlobalEventPluginViewSet, EmailTemplateViewSet,
|
||||
GlobalEventPluginViewSet,
|
||||
HolidayViewSet, TimeBlockViewSet, LocationViewSet,
|
||||
AlbumViewSet, MediaFileViewSet, StorageUsageView,
|
||||
)
|
||||
@@ -34,7 +34,6 @@ router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemp
|
||||
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
|
||||
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
|
||||
router.register(r'global-event-plugins', GlobalEventPluginViewSet, basename='globaleventplugin')
|
||||
router.register(r'email-templates', EmailTemplateViewSet, basename='emailtemplate')
|
||||
router.register(r'export', ExportViewSet, basename='export')
|
||||
router.register(r'holidays', HolidayViewSet, basename='holiday')
|
||||
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
|
||||
|
||||
@@ -11,14 +11,13 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from smoothschedule.communication.notifications.models import Notification
|
||||
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock, Location
|
||||
from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, Holiday, TimeBlock, Location
|
||||
from .serializers import (
|
||||
ResourceSerializer, EventSerializer, ParticipantSerializer,
|
||||
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
|
||||
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
|
||||
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer,
|
||||
EventPluginSerializer, GlobalEventPluginSerializer,
|
||||
EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer,
|
||||
HolidaySerializer, HolidayListSerializer,
|
||||
TimeBlockSerializer, TimeBlockListSerializer, BlockedDateSerializer, CheckConflictsSerializer,
|
||||
LocationSerializer,
|
||||
@@ -1748,256 +1747,6 @@ class GlobalEventPluginViewSet(viewsets.ModelViewSet):
|
||||
})
|
||||
|
||||
|
||||
class EmailTemplateViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing email templates.
|
||||
|
||||
Email templates can be used by plugins to send customized emails.
|
||||
Templates support variable substitution for dynamic content.
|
||||
|
||||
Access Control:
|
||||
- Business users see only BUSINESS scope templates (their own tenant's)
|
||||
- Platform users can also see/create PLATFORM scope templates (shared)
|
||||
|
||||
Permissions:
|
||||
- Subject to MAX_EMAIL_TEMPLATES quota (hard block on creation)
|
||||
|
||||
Endpoints:
|
||||
- GET /api/email-templates/ - List templates (filtered by scope/category)
|
||||
- POST /api/email-templates/ - Create template
|
||||
- GET /api/email-templates/{id}/ - Get template details
|
||||
- PATCH /api/email-templates/{id}/ - Update template
|
||||
- DELETE /api/email-templates/{id}/ - Delete template
|
||||
- POST /api/email-templates/preview/ - Render preview with sample data
|
||||
- POST /api/email-templates/{id}/duplicate/ - Create a copy
|
||||
- GET /api/email-templates/variables/ - Get available template variables
|
||||
"""
|
||||
queryset = EmailTemplate.objects.all()
|
||||
serializer_class = EmailTemplateSerializer
|
||||
permission_classes = [IsAuthenticated, HasQuota('MAX_EMAIL_TEMPLATES')]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter templates based on user type and query params"""
|
||||
user = self.request.user
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Platform users see all templates
|
||||
if hasattr(user, 'is_platform_user') and user.is_platform_user:
|
||||
scope = self.request.query_params.get('scope')
|
||||
if scope:
|
||||
queryset = queryset.filter(scope=scope.upper())
|
||||
else:
|
||||
# Business users only see BUSINESS scope templates
|
||||
queryset = queryset.filter(scope=EmailTemplate.Scope.BUSINESS)
|
||||
|
||||
# Filter by category if specified
|
||||
category = self.request.query_params.get('category')
|
||||
if category:
|
||||
queryset = queryset.filter(category=category.upper())
|
||||
|
||||
return queryset.order_by('name')
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use lightweight serializer for list view"""
|
||||
if self.action == 'list':
|
||||
return EmailTemplateListSerializer
|
||||
return EmailTemplateSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set created_by from request user"""
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def preview(self, request):
|
||||
"""
|
||||
Render a preview of the template with sample data.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"subject": "Hello {{CUSTOMER_NAME}}",
|
||||
"html_content": "<p>Your appointment is on {{APPOINTMENT_DATE}}</p>",
|
||||
"text_content": "Your appointment is on {{APPOINTMENT_DATE}}",
|
||||
"context": {"CUSTOMER_NAME": "John"} // optional overrides
|
||||
}
|
||||
|
||||
Response includes rendered content with force_footer flag for free tier.
|
||||
"""
|
||||
serializer = EmailTemplatePreviewSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
from .template_parser import TemplateVariableParser
|
||||
from datetime import datetime
|
||||
|
||||
context = serializer.validated_data.get('context', {})
|
||||
subject = serializer.validated_data['subject']
|
||||
html = serializer.validated_data.get('html_content', '')
|
||||
text = serializer.validated_data.get('text_content', '')
|
||||
|
||||
# Add default sample values for preview
|
||||
default_context = {
|
||||
'BUSINESS_NAME': 'Demo Business',
|
||||
'BUSINESS_EMAIL': 'contact@demo.com',
|
||||
'BUSINESS_PHONE': '(555) 123-4567',
|
||||
'CUSTOMER_NAME': 'John Doe',
|
||||
'CUSTOMER_EMAIL': 'john@example.com',
|
||||
'APPOINTMENT_TIME': 'Monday, January 15, 2025 at 2:00 PM',
|
||||
'APPOINTMENT_DATE': 'January 15, 2025',
|
||||
'APPOINTMENT_SERVICE': 'Consultation',
|
||||
'TODAY': datetime.now().strftime('%B %d, %Y'),
|
||||
'NOW': datetime.now().strftime('%B %d, %Y at %I:%M %p'),
|
||||
}
|
||||
default_context.update(context)
|
||||
|
||||
rendered_subject = TemplateVariableParser.replace_insertion_codes(subject, default_context)
|
||||
rendered_html = TemplateVariableParser.replace_insertion_codes(html, default_context) if html else ''
|
||||
rendered_text = TemplateVariableParser.replace_insertion_codes(text, default_context) if text else ''
|
||||
|
||||
# Check if free tier - append footer
|
||||
force_footer = False
|
||||
user = request.user
|
||||
if hasattr(user, 'is_platform_user') and not user.is_platform_user:
|
||||
from django.db import connection
|
||||
tenant = getattr(connection, 'tenant', None)
|
||||
if tenant:
|
||||
plan_code = None
|
||||
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
|
||||
plan_code = tenant.billing_subscription.plan_version.plan.code
|
||||
if plan_code == 'free' or not plan_code:
|
||||
force_footer = True
|
||||
|
||||
if force_footer:
|
||||
# Create a temporary instance just to use the footer methods
|
||||
temp = EmailTemplate()
|
||||
rendered_html = temp._append_html_footer(rendered_html)
|
||||
rendered_text = temp._append_text_footer(rendered_text)
|
||||
|
||||
return Response({
|
||||
'subject': rendered_subject,
|
||||
'html_content': rendered_html,
|
||||
'text_content': rendered_text,
|
||||
'force_footer': force_footer,
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def duplicate(self, request, pk=None):
|
||||
"""
|
||||
Create a copy of an existing template.
|
||||
|
||||
The copy will have "(Copy)" appended to its name.
|
||||
"""
|
||||
template = self.get_object()
|
||||
|
||||
new_template = EmailTemplate.objects.create(
|
||||
name=f"{template.name} (Copy)",
|
||||
description=template.description,
|
||||
subject=template.subject,
|
||||
html_content=template.html_content,
|
||||
text_content=template.text_content,
|
||||
scope=template.scope,
|
||||
category=template.category,
|
||||
preview_context=template.preview_context,
|
||||
created_by=request.user,
|
||||
)
|
||||
|
||||
serializer = EmailTemplateSerializer(new_template)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def variables(self, request):
|
||||
"""
|
||||
Get available template variables for the email template editor.
|
||||
|
||||
Returns variables grouped by category with descriptions.
|
||||
"""
|
||||
return Response({
|
||||
'variables': [
|
||||
{
|
||||
'category': 'Business',
|
||||
'items': [
|
||||
{'code': '{{BUSINESS_NAME}}', 'description': 'Business name'},
|
||||
{'code': '{{BUSINESS_EMAIL}}', 'description': 'Business contact email'},
|
||||
{'code': '{{BUSINESS_PHONE}}', 'description': 'Business phone number'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'category': 'Customer',
|
||||
'items': [
|
||||
{'code': '{{CUSTOMER_NAME}}', 'description': 'Customer full name'},
|
||||
{'code': '{{CUSTOMER_EMAIL}}', 'description': 'Customer email address'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'category': 'Appointment',
|
||||
'items': [
|
||||
{'code': '{{EVENT_START_DATETIME}}', 'description': 'Full date and time'},
|
||||
{'code': '{{EVENT_START_DATE}}', 'description': 'Date only'},
|
||||
{'code': '{{EVENT_START_TIME}}', 'description': 'Time only'},
|
||||
{'code': '{{EVENT_ID}}', 'description': 'Event/Appointment ID'},
|
||||
{'code': '{{SERVICE_NAME}}', 'description': 'Service name'},
|
||||
{'code': '{{SERVICE_DURATION}}', 'description': 'Service duration'},
|
||||
{'code': '{{SERVICE_PRICE}}', 'description': 'Service price'},
|
||||
{'code': '{{STAFF_NAME}}', 'description': 'Staff member name'},
|
||||
]
|
||||
},
|
||||
{
|
||||
'category': 'Date/Time',
|
||||
'items': [
|
||||
{'code': '{{TODAY}}', 'description': 'Current date'},
|
||||
{'code': '{{NOW}}', 'description': 'Current date and time'},
|
||||
]
|
||||
},
|
||||
],
|
||||
'categories': [choice[0] for choice in EmailTemplate.Category.choices],
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def presets(self, request):
|
||||
"""
|
||||
Get pre-built email template presets organized by category.
|
||||
|
||||
Users can select a preset and customize it to create their own template.
|
||||
Each category has multiple style variations (professional, friendly, minimalist).
|
||||
|
||||
Query params:
|
||||
- category: Filter presets by category (APPOINTMENT, REMINDER, etc.)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"presets": {
|
||||
"APPOINTMENT": [
|
||||
{
|
||||
"name": "Appointment Confirmation - Professional",
|
||||
"description": "Clean, professional...",
|
||||
"style": "professional",
|
||||
"subject": "...",
|
||||
"html_content": "...",
|
||||
"text_content": "..."
|
||||
},
|
||||
...
|
||||
],
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
from .email_template_presets import get_presets_by_category, get_all_presets
|
||||
|
||||
category = request.query_params.get('category')
|
||||
|
||||
if category:
|
||||
# Return presets for specific category
|
||||
category_upper = category.upper()
|
||||
presets = get_presets_by_category(category_upper)
|
||||
return Response({
|
||||
'category': category_upper,
|
||||
'presets': presets
|
||||
})
|
||||
else:
|
||||
# Return all presets organized by category
|
||||
return Response({
|
||||
'presets': get_all_presets()
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Time Blocking System ViewSets
|
||||
# =============================================================================
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Reference in New Issue
Block a user