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
BIN
frontend/public/logo-branding.png
Normal file
BIN
frontend/public/logo-branding.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
@@ -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"
|
||||
|
||||
285
frontend/src/pages/EmailTemplateEditor.tsx
Normal file
285
frontend/src/pages/EmailTemplateEditor.tsx
Normal file
@@ -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">
|
||||
|
||||
701
frontend/src/pages/settings/SystemEmailTemplates.tsx
Normal file
701
frontend/src/pages/settings/SystemEmailTemplates.tsx
Normal file
@@ -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;
|
||||
85
frontend/src/puck/components/email/EmailBranding.tsx
Normal file
85
frontend/src/puck/components/email/EmailBranding.tsx
Normal file
@@ -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;
|
||||
86
frontend/src/puck/components/email/EmailButton.tsx
Normal file
86
frontend/src/puck/components/email/EmailButton.tsx
Normal file
@@ -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;
|
||||
27
frontend/src/puck/components/email/EmailDivider.tsx
Normal file
27
frontend/src/puck/components/email/EmailDivider.tsx
Normal file
@@ -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;
|
||||
88
frontend/src/puck/components/email/EmailFooter.tsx
Normal file
88
frontend/src/puck/components/email/EmailFooter.tsx
Normal file
@@ -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;
|
||||
85
frontend/src/puck/components/email/EmailHeader.tsx
Normal file
85
frontend/src/puck/components/email/EmailHeader.tsx
Normal file
@@ -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;
|
||||
84
frontend/src/puck/components/email/EmailHeading.tsx
Normal file
84
frontend/src/puck/components/email/EmailHeading.tsx
Normal file
@@ -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;
|
||||
76
frontend/src/puck/components/email/EmailImage.tsx
Normal file
76
frontend/src/puck/components/email/EmailImage.tsx
Normal file
@@ -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;
|
||||
54
frontend/src/puck/components/email/EmailLayout.tsx
Normal file
54
frontend/src/puck/components/email/EmailLayout.tsx
Normal file
@@ -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;
|
||||
54
frontend/src/puck/components/email/EmailPanel.tsx
Normal file
54
frontend/src/puck/components/email/EmailPanel.tsx
Normal file
@@ -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;
|
||||
39
frontend/src/puck/components/email/EmailSpacer.tsx
Normal file
39
frontend/src/puck/components/email/EmailSpacer.tsx
Normal file
@@ -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;
|
||||
58
frontend/src/puck/components/email/EmailText.tsx
Normal file
58
frontend/src/puck/components/email/EmailText.tsx
Normal file
@@ -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;
|
||||
62
frontend/src/puck/components/email/EmailTwoColumn.tsx
Normal file
62
frontend/src/puck/components/email/EmailTwoColumn.tsx
Normal file
@@ -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;
|
||||
22
frontend/src/puck/components/email/index.ts
Normal file
22
frontend/src/puck/components/email/index.ts
Normal file
@@ -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';
|
||||
111
frontend/src/puck/components/email/types.ts
Normal file
111
frontend/src/puck/components/email/types.ts
Normal file
@@ -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>;
|
||||
}
|
||||
86
frontend/src/puck/emailConfig.tsx
Normal file
86
frontend/src/puck/emailConfig.tsx
Normal file
@@ -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;
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
BIN
frontend/test-results/email-preview-footer.png
Normal file
BIN
frontend/test-results/email-preview-footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
123
frontend/tests/e2e/email-preview-logo.spec.ts
Normal file
123
frontend/tests/e2e/email-preview-logo.spec.ts
Normal file
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
94
frontend/tests/e2e/email-template-editor.spec.ts
Normal file
94
frontend/tests/e2e/email-template-editor.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
29
frontend/tests/e2e/site-builder-test.spec.ts
Normal file
29
frontend/tests/e2e/site-builder-test.spec.ts
Normal file
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user