- Add SMTP fields to TicketEmailSettings model (host, port, TLS/SSL, credentials, from email/name) - Update serializers with SMTP fields and is_smtp_configured flag - Add TicketEmailTestSmtpView for testing SMTP connections - Update frontend API types and hooks for SMTP settings - Add collapsible IMAP and SMTP configuration sections with "Configured" badges - Fix TypeScript errors in mockData.ts (missing required fields, type mismatches) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
457 lines
20 KiB
TypeScript
457 lines
20 KiB
TypeScript
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
|
|
} from 'lucide-react';
|
|
import api from '../api/client';
|
|
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
|
|
|
|
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);
|
|
|
|
// Fetch available variables
|
|
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
|
|
queryKey: ['email-template-variables'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/api/email-templates/variables/');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
// Preview mutation
|
|
const previewMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const { data } = await api.post('/api/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(`/api/email-templates/${template.id}/`, payload);
|
|
return data;
|
|
} else {
|
|
const { data } = await api.post('/api/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 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">
|
|
<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>
|
|
<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 ${
|
|
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
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('text')}
|
|
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 ${
|
|
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
|
|
</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>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EmailTemplateForm;
|