Files
smoothschedule/frontend/src/components/EmailTemplateForm.tsx
poduck cfc1b36ada 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>
2025-11-29 18:28:29 -05:00

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;