/** * 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, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useOutletContext } from 'react-router-dom'; 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, Business, User, } from '../../types'; // Category metadata const CATEGORY_CONFIG: Record = { welcome: { label: 'Welcome & Onboarding', icon: , color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', }, appointment: { label: 'Appointments', icon: , color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', }, contract: { label: 'Contracts', icon: , color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', }, payment: { label: 'Payments', icon: , color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', }, ticket: { label: 'Support Tickets', icon: , 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 { user } = useOutletContext<{ business: Business; user: User; }>(); const isOwner = user.role === 'owner'; const hasPermission = isOwner || user.effective_permissions?.can_access_settings_email_templates === true; const [expandedCategories, setExpandedCategories] = useState>( new Set(CATEGORY_ORDER) ); const [editingTemplate, setEditingTemplate] = useState(null); const [showPreviewModal, setShowPreviewModal] = useState(false); const [previewHtml, setPreviewHtml] = useState(''); const [previewText, setPreviewText] = useState(''); const [previewSubject, setPreviewSubject] = useState(''); const [previewTab, setPreviewTab] = useState<'html' | 'text'>('html'); const [showResetConfirm, setShowResetConfirm] = useState(null); const [editorData, setEditorData] = useState(null); const [editorSubject, setEditorSubject] = useState(''); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // Get email editor config const editorConfig = getEmailEditorConfig(); // Fix Puck sidebar scrolling when editor is open useEffect(() => { if (!editingTemplate) return; const fixSidebars = () => { // Target only the main left/right sidebar containers const leftSidebar = document.querySelector('[class*="Sidebar--left"]') as HTMLElement; const rightSidebar = document.querySelector('[class*="Sidebar--right"]') as HTMLElement; [leftSidebar, rightSidebar].forEach((sidebar) => { if (!sidebar) return; // Make the main sidebar scroll sidebar.style.maxHeight = 'calc(100vh - 300px)'; sidebar.style.overflowY = 'auto'; // Remove overflow from inner sections so only the main sidebar scrolls const innerSections = sidebar.querySelectorAll('[class*="SidebarSection"]'); innerSections.forEach((section) => { (section as HTMLElement).style.overflow = 'visible'; (section as HTMLElement).style.maxHeight = 'none'; }); }); }; // Run after Puck renders const timer = setTimeout(fixSidebars, 200); return () => clearTimeout(timer); }, [editingTemplate]); // Fetch all email templates const { data: templates = [], isLoading } = useQuery({ 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 => { 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 = { 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}-${Math.random().toString(36).substring(2, 10)}`, }, }; } 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 (!hasPermission) { return (

{t('settings.noPermission', 'You do not have permission to access these settings.')}

); } if (isLoading) { return (
); } return (
{/* Header */}

{t('settings.systemEmails.title', 'System Email Templates')}

{t( 'settings.systemEmails.description', 'Customize the automated emails sent to your customers for appointments, payments, contracts, and more.' )}

{/* Info Banner */}

About Template Tags

Use template tags like {'{{ customer_name }}'} to insert dynamic content. Available tags vary by email type and are shown when editing each template.

{/* Template Categories */}
{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 (
{/* Category Header */} {/* Template List */} {isExpanded && (
{categoryTemplates.map((template) => (

{template.display_name}

{template.is_customized && ( Customized )} {!template.is_active && ( Disabled )}

{template.description}

Subject: {template.subject_template}

{/* Actions */}
{template.is_customized && ( )}
))}
)}
); })}
{/* Reset Confirmation Modal */} {showResetConfirm && (

Reset to Default?

This will reset the email template to its default content. Any customizations you've made will be lost.

)} {/* Editor Modal */} {editingTemplate && (
{/* Editor Header */}

{editingTemplate.display_name}

{editingTemplate.description}

{hasUnsavedChanges && ( Unsaved changes )}
{/* Subject Line Editor */}
{ 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..." />

Use tags like {'{{ customer_name }}'} for dynamic content

{/* Available Tags Panel */}
Available Template Tags ({editingTemplate.available_tags?.length || 0})
{/* Group tags by category */} {Object.entries( (editingTemplate.available_tags || []).reduce((acc: Record, tag: any) => { const category = tag.category || 'Other'; if (!acc[category]) acc[category] = []; acc[category].push(tag); return acc; }, {}) ).map(([category, tags]) => (

{category}

{(tags as any[]).map((tag: any) => ( { navigator.clipboard.writeText(`{{ ${tag.name} }}`); }} > {tag.name} ))}
))}

Click a tag to copy it. Hover for description.

{/* Puck Editor - using cloned config like site builder */}
{editorData && ( )}
)} {/* Preview Modal */} {showPreviewModal && (
{/* Preview Header */}

Email Preview

Subject: {previewSubject}

{/* Preview Tabs */}
{/* Preview Content */}
{previewTab === 'html' ? (