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:
poduck
2025-12-14 19:10:56 -05:00
parent fbefccf436
commit 89fa8f81af
80 changed files with 7398 additions and 7908 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,71 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e1]:
- generic [ref=e3]:
- generic [ref=e5]:
- button "Collapse sidebar" [ref=e6]:
- img [ref=e7]
- generic [ref=e13]:
- heading "Smooth Schedule" [level=1] [ref=e14]
- paragraph [ref=e15]: superuser
- navigation [ref=e16]:
- paragraph [ref=e17]: Operations
- link "Dashboard" [ref=e18] [cursor=pointer]:
- /url: /platform/dashboard
- img [ref=e19]
- generic [ref=e24]: Dashboard
- link "Businesses" [ref=e25] [cursor=pointer]:
- /url: /platform/businesses
- img [ref=e26]
- generic [ref=e30]: Businesses
- link "Users" [ref=e31] [cursor=pointer]:
- /url: /platform/users
- img [ref=e32]
- generic [ref=e37]: Users
- link "Support" [active] [ref=e38] [cursor=pointer]:
- /url: /platform/support
- img [ref=e39]
- generic [ref=e41]: Support
- paragraph [ref=e42]: System
- link "Staff" [ref=e43] [cursor=pointer]:
- /url: /platform/staff
- img [ref=e44]
- generic [ref=e46]: Staff
- link "Platform Settings" [ref=e47] [cursor=pointer]:
- /url: /platform/settings
- img [ref=e48]
- generic [ref=e51]: Platform Settings
- generic [ref=e52]:
- link "Help" [ref=e53] [cursor=pointer]:
- /url: /help/ticketing
- img [ref=e54]
- generic [ref=e57]: Help
- link "API Docs" [ref=e58] [cursor=pointer]:
- /url: /help/api
- img [ref=e59]
- generic [ref=e62]: API Docs
- generic [ref=e63]:
- banner [ref=e64]:
- generic [ref=e66]:
- img [ref=e67]
- generic [ref=e70]: smoothschedule.com
- generic [ref=e71]: /
- generic [ref=e72]: Admin Console
- generic [ref=e73]:
- button [ref=e74]:
- img [ref=e75]
- button "Open notifications" [ref=e78]:
- img [ref=e79]
- button "Super User Superuser SU" [ref=e83]:
- generic [ref=e84]:
- paragraph [ref=e85]: Super User
- paragraph [ref=e86]: Superuser
- generic [ref=e87]: SU
- img [ref=e88]
- main [ref=e90]:
- generic [ref=e91]:
- img [ref=e92]
- paragraph [ref=e94]: Error loading tickets
- generic [ref=e95]: $0k
```

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -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 />} />

View File

@@ -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'}`);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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.');
});
});

View File

@@ -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;
}

View File

@@ -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"

View 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

View File

@@ -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'));

View File

@@ -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">

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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>;
}

View 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;

View File

@@ -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;
}

View File

@@ -1,6 +1,7 @@
{
"status": "failed",
"failedTests": [
"9a7e4977473ed55fa848-618b0c2ae07e5089ab92"
"6f1a4b04e7ad1ff99f24-d68c404526a42bfecb67",
"6f1a4b04e7ad1ff99f24-6b3beabbc695cf50d356"
]
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View 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');
}
});
});

View 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);
});
});

View 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));
});