feat: Add SMTP settings and collapsible email configuration UI
- 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>
This commit is contained in:
456
frontend/src/components/EmailTemplateForm.tsx
Normal file
456
frontend/src/components/EmailTemplateForm.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user