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:
701
frontend/src/pages/settings/SystemEmailTemplates.tsx
Normal file
701
frontend/src/pages/settings/SystemEmailTemplates.tsx
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* System Email Templates Settings Page
|
||||
*
|
||||
* Allows businesses to customize their automated system emails
|
||||
* (welcome, appointment confirmations, reminders, etc.) using a Puck editor.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Puck, Render } from '@measured/puck';
|
||||
import '@measured/puck/puck.css';
|
||||
import {
|
||||
Mail,
|
||||
Edit2,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
X,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
FileSignature,
|
||||
CreditCard,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Save,
|
||||
Loader2,
|
||||
Code,
|
||||
MonitorPlay,
|
||||
} from 'lucide-react';
|
||||
import api from '../../api/client';
|
||||
import { getEmailEditorConfig } from '../../puck/emailConfig';
|
||||
import {
|
||||
SystemEmailTemplate,
|
||||
SystemEmailTemplateDetail,
|
||||
SystemEmailTag,
|
||||
SystemEmailCategory,
|
||||
SystemEmailType,
|
||||
} from '../../types';
|
||||
|
||||
// Category metadata
|
||||
const CATEGORY_CONFIG: Record<SystemEmailCategory, {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}> = {
|
||||
welcome: {
|
||||
label: 'Welcome & Onboarding',
|
||||
icon: <UserPlus className="h-5 w-5" />,
|
||||
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
},
|
||||
appointment: {
|
||||
label: 'Appointments',
|
||||
icon: <Calendar className="h-5 w-5" />,
|
||||
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
},
|
||||
contract: {
|
||||
label: 'Contracts',
|
||||
icon: <FileSignature className="h-5 w-5" />,
|
||||
color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
},
|
||||
payment: {
|
||||
label: 'Payments',
|
||||
icon: <CreditCard className="h-5 w-5" />,
|
||||
color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
},
|
||||
ticket: {
|
||||
label: 'Support Tickets',
|
||||
icon: <Ticket className="h-5 w-5" />,
|
||||
color: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
|
||||
},
|
||||
};
|
||||
|
||||
// Category order for display
|
||||
const CATEGORY_ORDER: SystemEmailCategory[] = [
|
||||
'welcome',
|
||||
'appointment',
|
||||
'contract',
|
||||
'payment',
|
||||
'ticket',
|
||||
];
|
||||
|
||||
const SystemEmailTemplates: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<SystemEmailCategory>>(
|
||||
new Set(CATEGORY_ORDER)
|
||||
);
|
||||
const [editingTemplate, setEditingTemplate] = useState<SystemEmailTemplateDetail | null>(null);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||
const [previewText, setPreviewText] = useState<string>('');
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
const [previewTab, setPreviewTab] = useState<'html' | 'text'>('html');
|
||||
const [showResetConfirm, setShowResetConfirm] = useState<string | null>(null);
|
||||
const [editorData, setEditorData] = useState<any>(null);
|
||||
const [editorSubject, setEditorSubject] = useState<string>('');
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// Get email editor config
|
||||
const editorConfig = getEmailEditorConfig();
|
||||
|
||||
// Fetch all email templates
|
||||
const { data: templates = [], isLoading } = useQuery<SystemEmailTemplate[]>({
|
||||
queryKey: ['system-email-templates'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/messages/email-templates/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch single template with tags
|
||||
const fetchTemplateDetail = async (emailType: SystemEmailType): Promise<SystemEmailTemplateDetail> => {
|
||||
const { data } = await api.get(`/messages/email-templates/${emailType}/`);
|
||||
return data;
|
||||
};
|
||||
|
||||
// Update template mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ emailType, data }: { emailType: SystemEmailType; data: any }) => {
|
||||
const response = await api.patch(`/messages/email-templates/${emailType}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system-email-templates'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Reset template mutation
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (emailType: SystemEmailType) => {
|
||||
const response = await api.post(`/messages/email-templates/${emailType}/reset/`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['system-email-templates'] });
|
||||
setShowResetConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async ({ emailType, data }: { emailType: SystemEmailType; data: any }) => {
|
||||
const response = await api.post(`/messages/email-templates/${emailType}/preview/`, data);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Group templates by category
|
||||
const templatesByCategory = React.useMemo(() => {
|
||||
const grouped: Record<SystemEmailCategory, SystemEmailTemplate[]> = {
|
||||
welcome: [],
|
||||
appointment: [],
|
||||
contract: [],
|
||||
payment: [],
|
||||
ticket: [],
|
||||
};
|
||||
|
||||
templates.forEach((template) => {
|
||||
if (grouped[template.category]) {
|
||||
grouped[template.category].push(template);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}, [templates]);
|
||||
|
||||
// Toggle category expansion
|
||||
const toggleCategory = (category: SystemEmailCategory) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform puck_data to ensure id is inside props (Puck requirement).
|
||||
* Puck expects: { type: 'X', props: { id: 'X-1', ... } }
|
||||
* API may return: { type: 'X', id: 'X-1', props: { ... } }
|
||||
*/
|
||||
const transformPuckDataForEditor = (puckData: any): any => {
|
||||
if (!puckData?.content) return puckData;
|
||||
|
||||
return {
|
||||
...puckData,
|
||||
content: puckData.content.map((item: any, index: number) => {
|
||||
// If id is at root level but not in props, move it inside props
|
||||
const rootId = item.id;
|
||||
const hasPropsId = item.props?.id;
|
||||
|
||||
if (rootId && !hasPropsId) {
|
||||
// Move id from root to inside props
|
||||
const { id, ...rest } = item;
|
||||
return {
|
||||
...rest,
|
||||
props: {
|
||||
...rest.props,
|
||||
id: rootId,
|
||||
},
|
||||
};
|
||||
} else if (!hasPropsId) {
|
||||
// Generate id if missing entirely
|
||||
return {
|
||||
...item,
|
||||
props: {
|
||||
...item.props,
|
||||
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
// Open editor for a template
|
||||
const handleEdit = async (template: SystemEmailTemplate) => {
|
||||
try {
|
||||
const detail = await fetchTemplateDetail(template.email_type);
|
||||
|
||||
// Transform the data to ensure Puck can render it correctly
|
||||
const transformedData = transformPuckDataForEditor(detail.puck_data);
|
||||
|
||||
setEditingTemplate(detail);
|
||||
setEditorData(transformedData);
|
||||
setEditorSubject(detail.subject_template);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle data changes in editor
|
||||
const handleEditorChange = useCallback((newData: any) => {
|
||||
setEditorData(newData);
|
||||
setHasUnsavedChanges(true);
|
||||
}, []);
|
||||
|
||||
// Save template
|
||||
const handleSave = async () => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
emailType: editingTemplate.email_type,
|
||||
data: {
|
||||
subject_template: editorSubject,
|
||||
puck_data: editorData,
|
||||
},
|
||||
});
|
||||
setHasUnsavedChanges(false);
|
||||
setEditingTemplate(null);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save template:', error);
|
||||
// Show error message from API if available
|
||||
const errorMsg = error?.response?.data?.subject_template?.[0] ||
|
||||
error?.response?.data?.error ||
|
||||
'Failed to save template';
|
||||
alert(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// Preview template
|
||||
const handlePreview = async () => {
|
||||
if (!editingTemplate) return;
|
||||
|
||||
try {
|
||||
const preview = await previewMutation.mutateAsync({
|
||||
emailType: editingTemplate.email_type,
|
||||
data: {
|
||||
subject_template: editorSubject,
|
||||
puck_data: editorData,
|
||||
},
|
||||
});
|
||||
setPreviewSubject(preview.subject);
|
||||
setPreviewHtml(preview.html);
|
||||
setPreviewText(preview.text);
|
||||
setShowPreviewModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate preview:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset template to default
|
||||
const handleReset = async (emailType: SystemEmailType) => {
|
||||
try {
|
||||
await resetMutation.mutateAsync(emailType);
|
||||
} catch (error) {
|
||||
console.error('Failed to reset template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Close editor
|
||||
const handleCloseEditor = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEditingTemplate(null);
|
||||
setEditorData(null);
|
||||
setEditorSubject('');
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Mail className="h-7 w-7 text-brand-600" />
|
||||
{t('settings.systemEmails.title', 'System Email Templates')}
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'settings.systemEmails.description',
|
||||
'Customize the automated emails sent to your customers for appointments, payments, contracts, and more.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<p className="font-medium mb-1">About Template Tags</p>
|
||||
<p>
|
||||
Use template tags like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{{ customer_name }}'}</code> to
|
||||
insert dynamic content. Available tags vary by email type and are shown when editing each template.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Categories */}
|
||||
<div className="space-y-4">
|
||||
{CATEGORY_ORDER.map((category) => {
|
||||
const categoryTemplates = templatesByCategory[category];
|
||||
const config = CATEGORY_CONFIG[category];
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
if (categoryTemplates.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={category}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`p-2 rounded-lg ${config.color}`}>{config.icon}</span>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{config.label}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{categoryTemplates.length} template{categoryTemplates.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Template List */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{categoryTemplates.map((template) => (
|
||||
<div
|
||||
key={template.email_type}
|
||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{template.display_name}
|
||||
</h4>
|
||||
{template.is_customized && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
|
||||
Customized
|
||||
</span>
|
||||
)}
|
||||
{!template.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{template.description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Subject: <span className="font-mono">{template.subject_template}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{template.is_customized && (
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(template.email_type)}
|
||||
className="p-2 text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(template)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reset Confirmation Modal */}
|
||||
{showResetConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Reset to Default?
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This will reset the email template to its default content. Any customizations you've made will be lost.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowResetConfirm(null)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReset(showResetConfirm as SystemEmailType)}
|
||||
disabled={resetMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors font-medium"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Modal */}
|
||||
{editingTemplate && (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
|
||||
{/* Editor Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleCloseEditor}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
{editingTemplate.display_name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{editingTemplate.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
|
||||
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={previewMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending || !hasUnsavedChanges}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 text-sm font-medium transition-colors"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subject Line Editor */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editorSubject}
|
||||
onChange={(e) => {
|
||||
setEditorSubject(e.target.value);
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder="Enter email subject..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Use tags like <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{ customer_name }}'}</code> for dynamic content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available Tags Panel */}
|
||||
<div className="bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
|
||||
<details className="group" open>
|
||||
<summary className="flex items-center gap-2 cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<Code className="h-4 w-4" />
|
||||
Available Template Tags ({editingTemplate.available_tags?.length || 0})
|
||||
<ChevronRight className="h-4 w-4 group-open:rotate-90 transition-transform" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Group tags by category */}
|
||||
{Object.entries(
|
||||
(editingTemplate.available_tags || []).reduce((acc: Record<string, any[]>, tag: any) => {
|
||||
const category = tag.category || 'Other';
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(tag);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([category, tags]) => (
|
||||
<div key={category}>
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1.5">{category}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(tags as any[]).map((tag: any) => (
|
||||
<span
|
||||
key={tag.name}
|
||||
className="inline-flex items-center px-2 py-0.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-xs font-mono cursor-help hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
title={tag.description}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`{{ ${tag.name} }}`);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Click a tag to copy it. Hover for description.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Puck Editor - using cloned config like site builder */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{editorData && (
|
||||
<Puck
|
||||
key={`email-puck-${editingTemplate?.email_type}`}
|
||||
config={editorConfig}
|
||||
data={editorData}
|
||||
onChange={handleEditorChange}
|
||||
onPublish={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreviewModal && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Preview Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<MonitorPlay className="h-5 w-5 text-brand-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Email Preview</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Subject: {previewSubject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowPreviewModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Preview Tabs */}
|
||||
<div className="px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setPreviewTab('html')}
|
||||
className={`py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
previewTab === 'html'
|
||||
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
HTML Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewTab('text')}
|
||||
className={`py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
previewTab === 'text'
|
||||
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Plain Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="flex-1 overflow-auto p-6 bg-gray-50 dark:bg-gray-900">
|
||||
{previewTab === 'html' ? (
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden max-w-2xl mx-auto">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
className="w-full h-[500px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="bg-white dark:bg-gray-800 p-4 rounded-lg font-mono text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap max-w-2xl mx-auto">
|
||||
{previewText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowPreviewModal(false)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemEmailTemplates;
|
||||
Reference in New Issue
Block a user