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:
@@ -145,12 +145,12 @@ export function DevQuickLogin({ embedded = false, filter = 'all' }: DevQuickLogi
|
||||
|
||||
if (needsRedirect) {
|
||||
// Redirect to the correct subdomain
|
||||
window.location.href = buildSubdomainUrl(targetSubdomain, '/');
|
||||
window.location.href = buildSubdomainUrl(targetSubdomain, '/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Already on correct subdomain - just reload to update auth state
|
||||
window.location.reload();
|
||||
// Already on correct subdomain - navigate to dashboard
|
||||
window.location.href = '/dashboard';
|
||||
} catch (error: any) {
|
||||
console.error('Quick login failed:', error);
|
||||
alert(`Failed to login as ${user.label}: ${error.message || 'Unknown error'}`);
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
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,
|
||||
Sparkles,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory, EmailTemplateVariableGroup } from '../types';
|
||||
import EmailTemplatePresetSelector from './EmailTemplatePresetSelector';
|
||||
|
||||
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);
|
||||
const [showPresetSelector, setShowPresetSelector] = useState(false);
|
||||
const [showTwoVersionsWarning, setShowTwoVersionsWarning] = useState(() => {
|
||||
// Check localStorage to see if user has dismissed the warning
|
||||
try {
|
||||
return localStorage.getItem('emailTemplates_twoVersionsWarning_dismissed') !== 'true';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch available variables
|
||||
const { data: variablesData } = useQuery<{ variables: EmailTemplateVariableGroup[] }>({
|
||||
queryKey: ['email-template-variables'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/email-templates/variables/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.post('/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(`/email-templates/${template.id}/`, payload);
|
||||
return data;
|
||||
} else {
|
||||
const { data } = await api.post('/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 handlePresetSelect = (preset: any) => {
|
||||
setName(preset.name);
|
||||
setDescription(preset.description);
|
||||
setSubject(preset.subject);
|
||||
setHtmlContent(preset.html_content);
|
||||
setTextContent(preset.text_content);
|
||||
setShowPresetSelector(false);
|
||||
};
|
||||
|
||||
const handleDismissTwoVersionsWarning = () => {
|
||||
setShowTwoVersionsWarning(false);
|
||||
try {
|
||||
localStorage.setItem('emailTemplates_twoVersionsWarning_dismissed', 'true');
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
{/* Choose from Preset Button */}
|
||||
{!isEditing && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPresetSelector(true)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition-all shadow-md hover:shadow-lg font-medium"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{t('emailTemplates.chooseFromPreset', 'Choose from Pre-designed Templates')}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2">
|
||||
{t('emailTemplates.presetHint', 'Start with a professionally designed template and customize it to your needs')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{/* Info callout about HTML and Text versions */}
|
||||
{showTwoVersionsWarning && (
|
||||
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
||||
{t('emailTemplates.twoVersionsRequired', 'Please edit both email versions')}
|
||||
</h4>
|
||||
<p className="text-xs text-blue-800 dark:text-blue-300 leading-relaxed mb-3">
|
||||
{t('emailTemplates.twoVersionsExplanation', 'Your customers will receive one of two versions of this email depending on their email client. Edit both the HTML version (rich formatting) and the Plain Text version (simple text) below. Make sure both versions include the same information so all your customers get the complete message.')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismissTwoVersionsWarning}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 dark:bg-blue-500 text-white text-xs font-medium rounded hover:bg-blue-700 dark:hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('emailTemplates.iUnderstand', 'I Understand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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 relative ${
|
||||
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
|
||||
{!htmlContent.trim() && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('text')}
|
||||
className={`px-4 py-2 text-sm font-medium flex items-center gap-2 relative ${
|
||||
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
|
||||
{!textContent.trim() && (
|
||||
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Preset Selector Modal */}
|
||||
{showPresetSelector && (
|
||||
<EmailTemplatePresetSelector
|
||||
category={category}
|
||||
onSelect={handlePresetSelect}
|
||||
onClose={() => setShowPresetSelector(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplateForm;
|
||||
@@ -1,292 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
Eye,
|
||||
Check,
|
||||
Sparkles,
|
||||
Smile,
|
||||
Minus,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplateCategory } from '../types';
|
||||
|
||||
interface TemplatePreset {
|
||||
name: string;
|
||||
description: string;
|
||||
style: string;
|
||||
subject: string;
|
||||
html_content: string;
|
||||
text_content: string;
|
||||
}
|
||||
|
||||
interface PresetsResponse {
|
||||
presets: Record<EmailTemplateCategory, TemplatePreset[]>;
|
||||
}
|
||||
|
||||
interface EmailTemplatePresetSelectorProps {
|
||||
category: EmailTemplateCategory;
|
||||
onSelect: (preset: TemplatePreset) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const styleIcons: Record<string, React.ReactNode> = {
|
||||
professional: <Sparkles className="h-4 w-4" />,
|
||||
friendly: <Smile className="h-4 w-4" />,
|
||||
minimalist: <Minus className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const styleColors: Record<string, string> = {
|
||||
professional: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
friendly: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
minimalist: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
const EmailTemplatePresetSelector: React.FC<EmailTemplatePresetSelectorProps> = ({
|
||||
category,
|
||||
onSelect,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPreview, setSelectedPreview] = useState<TemplatePreset | null>(null);
|
||||
const [selectedStyle, setSelectedStyle] = useState<string>('all');
|
||||
|
||||
// Fetch presets
|
||||
const { data: presetsData, isLoading } = useQuery<PresetsResponse>({
|
||||
queryKey: ['email-template-presets'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/email-templates/presets/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const presets = presetsData?.presets[category] || [];
|
||||
|
||||
// Filter presets
|
||||
const filteredPresets = presets.filter(preset => {
|
||||
const matchesSearch = searchQuery.trim() === '' ||
|
||||
preset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
preset.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStyle = selectedStyle === 'all' || preset.style === selectedStyle;
|
||||
|
||||
return matchesSearch && matchesStyle;
|
||||
});
|
||||
|
||||
// Get unique styles from presets
|
||||
const availableStyles = Array.from(new Set(presets.map(p => p.style)));
|
||||
|
||||
const handleSelectPreset = (preset: TemplatePreset) => {
|
||||
onSelect(preset);
|
||||
onClose();
|
||||
};
|
||||
|
||||
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-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('emailTemplates.selectPreset', 'Choose a Template')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.presetDescription', 'Select a pre-designed template to customize')}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('emailTemplates.searchPresets', 'Search templates...')}
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Style Filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedStyle('all')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === 'all'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
All Styles
|
||||
</button>
|
||||
{availableStyles.map(style => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => setSelectedStyle(style)}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
selectedStyle === style
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{styleIcons[style]}
|
||||
<span className="capitalize">{style}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : filteredPresets.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.noPresets', 'No templates found matching your criteria')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPresets.map((preset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group"
|
||||
>
|
||||
{/* Preview Image Placeholder */}
|
||||
<div className="h-40 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-600 dark:to-gray-700 relative overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<iframe
|
||||
srcDoc={preset.html_content}
|
||||
className="w-full h-full pointer-events-none transform scale-50 origin-top-left"
|
||||
style={{ width: '200%', height: '200%' }}
|
||||
title={preset.name}
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-4">
|
||||
<button
|
||||
onClick={() => setSelectedPreview(preset)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-white/90 dark:bg-gray-800/90 text-gray-900 dark:text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white line-clamp-1">
|
||||
{preset.name}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${styleColors[preset.style] || styleColors.professional}`}>
|
||||
{styleIcons[preset.style]}
|
||||
<span className="capitalize">{preset.style}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">
|
||||
{preset.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleSelectPreset(preset)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview Modal */}
|
||||
{selectedPreview && (
|
||||
<div className="fixed inset-0 z-60 flex items-center justify-center bg-black/70 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">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPreview.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{selectedPreview.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPreview(null)}
|
||||
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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm">
|
||||
{selectedPreview.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Preview
|
||||
</label>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={selectedPreview.html_content}
|
||||
className="w-full h-96 bg-white"
|
||||
title="Template Preview"
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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={() => setSelectedPreview(null)}
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectPreset(selectedPreview)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Use This Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplatePresetSelector;
|
||||
@@ -1,9 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Mail, ExternalLink } from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate } from '../types';
|
||||
import { AlertTriangle, Mail } from 'lucide-react';
|
||||
|
||||
interface EmailTemplateSelectorProps {
|
||||
value: string | number | undefined;
|
||||
@@ -15,96 +12,46 @@ interface EmailTemplateSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DEPRECATED: Custom email templates are no longer supported.
|
||||
*
|
||||
* The email template system has been replaced with system-level templates
|
||||
* that are managed through Business Settings > Email Templates.
|
||||
*
|
||||
* This component now displays a deprecation notice instead of a selector.
|
||||
*/
|
||||
const EmailTemplateSelector: React.FC<EmailTemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
category,
|
||||
placeholder,
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Fetch email templates
|
||||
const { data: templates = [], isLoading } = useQuery<EmailTemplate[]>({
|
||||
queryKey: ['email-templates-list', category],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.append('category', category);
|
||||
const { data } = await api.get(`/email-templates/?${params.toString()}`);
|
||||
return data.map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
category: t.category,
|
||||
scope: t.scope,
|
||||
updatedAt: t.updated_at,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTemplate = templates.find(t => String(t.id) === String(value));
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${className}`}>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value || undefined)}
|
||||
disabled={disabled || isLoading}
|
||||
className="w-full pl-10 pr-4 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 disabled:opacity-50 disabled:cursor-not-allowed appearance-none"
|
||||
>
|
||||
<option value="">
|
||||
{isLoading
|
||||
? t('common.loading', 'Loading...')
|
||||
: placeholder || t('emailTemplates.selectTemplate', 'Select a template...')}
|
||||
</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
{template.category !== 'OTHER' && ` (${template.category})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<div className="flex items-start gap-3 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-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('emailTemplates.deprecated.title', 'Custom Email Templates Deprecated')}
|
||||
</p>
|
||||
<p className="text-amber-700 dark:text-amber-300 mt-1">
|
||||
{t(
|
||||
'emailTemplates.deprecated.message',
|
||||
'Custom email templates have been replaced with system email templates. You can customize system emails (appointment confirmations, reminders, etc.) in Business Settings > Email Templates.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected template info */}
|
||||
{selectedTemplate && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400 truncate">
|
||||
{selectedTemplate.description || selectedTemplate.name}
|
||||
</span>
|
||||
<a
|
||||
href={`#/email-templates`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-brand-600 dark:text-brand-400 hover:underline ml-2 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('common.edit', 'Edit')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state with link to create */}
|
||||
{!isLoading && templates.length === 0 && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('emailTemplates.noTemplatesYet', 'No email templates yet.')}{' '}
|
||||
<a
|
||||
href="#/email-templates"
|
||||
className="text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('emailTemplates.createFirst', 'Create your first template')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative opacity-50 pointer-events-none">
|
||||
<select
|
||||
disabled
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 cursor-not-allowed appearance-none"
|
||||
>
|
||||
<option value="">
|
||||
{t('emailTemplates.deprecated.unavailable', 'Custom templates no longer available')}
|
||||
</option>
|
||||
</select>
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -160,7 +160,6 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/gallery"
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import EmailTemplateSelector from '../EmailTemplateSelector';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) => defaultValue || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock API client
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(() => Promise.resolve({ data: [] })),
|
||||
},
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('EmailTemplateSelector', () => {
|
||||
it('renders select element', () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows placeholder text after loading', async () => {
|
||||
render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
placeholder="Select a template"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
// Wait for loading to finish and placeholder to appear
|
||||
await screen.findByText('Select a template');
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} disabled />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(screen.getByRole('combobox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<EmailTemplateSelector
|
||||
value={undefined}
|
||||
onChange={() => {}}
|
||||
className="custom-class"
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('shows empty state message when no templates', async () => {
|
||||
render(
|
||||
<EmailTemplateSelector value={undefined} onChange={() => {}} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
// Wait for loading to finish
|
||||
await screen.findByText('No email templates yet.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user