- Add PlatformEmailTemplate model and API for superuser-managed email templates - Add PlatformStaffInvitation model with email sending via Celery tasks - Add platform staff invite page and acceptance flow with auto-login - Add quota tracking models (DailyAppointmentUsage, DailyAPIUsage, StorageUsage) - Add quota status API endpoints and frontend banners - Add storage usage service for tenant media tracking - Fix platform user deletion with raw SQL to handle multi-tenant FK constraints - Update EditPlatformUserModal with archive/delete buttons - Update PlatformSidebar with email templates link for superusers - Configure console email backend and Celery eager mode for local development 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
754 lines
29 KiB
TypeScript
754 lines
29 KiB
TypeScript
/**
|
|
* 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<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 { 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<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();
|
|
|
|
// 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<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}-${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 (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
{t('settings.noPermission', 'You do not have permission to access these settings.')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|