- Add email template presets for Browse Templates tab (12 templates) - Add bulk selection and deletion for My Templates tab - Add communication credits system with Twilio integration - Add payment views for credit purchases and auto-reload - Add SMS reminder and masked calling plan permissions - Fix appointment status mapping (frontend/backend mismatch) - Clear masquerade stack on login/logout for session hygiene - Update platform settings with credit configuration - Add new migrations for Twilio and Stripe payment fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1039 lines
45 KiB
TypeScript
1039 lines
45 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
Mail,
|
|
Plus,
|
|
Search,
|
|
Filter,
|
|
Edit2,
|
|
Trash2,
|
|
Copy,
|
|
Eye,
|
|
X,
|
|
Calendar,
|
|
Bell,
|
|
CheckCircle,
|
|
Megaphone,
|
|
FileText,
|
|
BarChart3,
|
|
Package,
|
|
AlertTriangle,
|
|
Sparkles,
|
|
Smile,
|
|
Minus,
|
|
Grid3x3,
|
|
List,
|
|
Check,
|
|
Square,
|
|
CheckSquare
|
|
} from 'lucide-react';
|
|
import api from '../api/client';
|
|
import { EmailTemplate, EmailTemplateCategory } from '../types';
|
|
import EmailTemplateForm from '../components/EmailTemplateForm';
|
|
|
|
// Category icon mapping
|
|
const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
|
|
APPOINTMENT: <Calendar className="h-4 w-4" />,
|
|
REMINDER: <Bell className="h-4 w-4" />,
|
|
CONFIRMATION: <CheckCircle className="h-4 w-4" />,
|
|
MARKETING: <Megaphone className="h-4 w-4" />,
|
|
NOTIFICATION: <FileText className="h-4 w-4" />,
|
|
REPORT: <BarChart3 className="h-4 w-4" />,
|
|
OTHER: <Package className="h-4 w-4" />,
|
|
};
|
|
|
|
// Category colors
|
|
const categoryColors: Record<EmailTemplateCategory, string> = {
|
|
APPOINTMENT: 'bg-indigo-50 text-indigo-700 ring-indigo-700/20 dark:bg-indigo-400/10 dark:text-indigo-400 ring-indigo-400/30',
|
|
REMINDER: 'bg-orange-50 text-orange-700 ring-orange-700/20 dark:bg-orange-400/10 dark:text-orange-400 ring-orange-400/30',
|
|
CONFIRMATION: 'bg-green-50 text-green-700 ring-green-700/20 dark:bg-green-400/10 dark:text-green-400 ring-green-400/30',
|
|
MARKETING: 'bg-purple-50 text-purple-700 ring-purple-700/20 dark:bg-purple-400/10 dark:text-purple-400 ring-purple-400/30',
|
|
NOTIFICATION: 'bg-sky-50 text-sky-700 ring-sky-700/20 dark:bg-sky-400/10 dark:text-sky-400 ring-sky-400/30',
|
|
REPORT: 'bg-rose-50 text-rose-700 ring-rose-700/20 dark:bg-rose-400/10 dark:text-rose-400 ring-rose-400/30',
|
|
OTHER: 'bg-gray-50 text-gray-700 ring-gray-700/20 dark:bg-gray-400/10 dark:text-gray-400 ring-gray-400/30',
|
|
};
|
|
|
|
interface TemplatePreset {
|
|
name: string;
|
|
description: string;
|
|
style: string;
|
|
subject: string;
|
|
html_content: string;
|
|
text_content: string;
|
|
}
|
|
|
|
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-purple-50 text-purple-700 ring-purple-700/20 dark:bg-purple-400/10 dark:text-purple-400 ring-purple-400/30',
|
|
friendly: 'bg-green-50 text-green-700 ring-green-700/20 dark:bg-green-400/10 dark:text-green-400 ring-green-400/30',
|
|
minimalist: 'bg-gray-50 text-gray-700 ring-gray-700/20 dark:bg-gray-400/10 dark:text-gray-400 ring-gray-400/30',
|
|
};
|
|
|
|
const EmailTemplates: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [activeView, setActiveView] = useState<'my-templates' | 'browse'>('browse');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState<EmailTemplateCategory | 'ALL'>('ALL');
|
|
const [selectedStyle, setSelectedStyle] = useState<string>('all');
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [templateToDelete, setTemplateToDelete] = useState<EmailTemplate | null>(null);
|
|
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
|
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
|
const [previewPreset, setPreviewPreset] = useState<TemplatePreset | null>(null);
|
|
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(new Set());
|
|
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
|
|
|
|
// Fetch email templates
|
|
const { data: templates = [], isLoading, error } = useQuery<EmailTemplate[]>({
|
|
queryKey: ['email-templates'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/email-templates/');
|
|
return data.map((t: any) => ({
|
|
id: String(t.id),
|
|
name: t.name,
|
|
description: t.description,
|
|
subject: t.subject,
|
|
htmlContent: t.html_content,
|
|
textContent: t.text_content,
|
|
scope: t.scope,
|
|
isDefault: t.is_default,
|
|
category: t.category,
|
|
previewContext: t.preview_context,
|
|
createdBy: t.created_by,
|
|
createdByName: t.created_by_name,
|
|
createdAt: t.created_at,
|
|
updatedAt: t.updated_at,
|
|
}));
|
|
},
|
|
});
|
|
|
|
// Fetch template presets
|
|
const { data: presetsData, isLoading: presetsLoading } = useQuery<{ presets: Record<EmailTemplateCategory, TemplatePreset[]> }>({
|
|
queryKey: ['email-template-presets'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get('/email-templates/presets/');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
// Delete template mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (templateId: string) => {
|
|
await api.delete(`/email-templates/${templateId}/`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
|
setShowDeleteModal(false);
|
|
setTemplateToDelete(null);
|
|
},
|
|
});
|
|
|
|
// Duplicate template mutation
|
|
const duplicateMutation = useMutation({
|
|
mutationFn: async (templateId: string) => {
|
|
const { data } = await api.post(`/email-templates/${templateId}/duplicate/`);
|
|
return data;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
|
},
|
|
});
|
|
|
|
// Bulk delete mutation
|
|
const bulkDeleteMutation = useMutation({
|
|
mutationFn: async (templateIds: string[]) => {
|
|
// Delete templates one by one (backend may not support bulk delete)
|
|
await Promise.all(templateIds.map(id => api.delete(`/email-templates/${id}/`)));
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
|
setSelectedTemplates(new Set());
|
|
setShowBulkDeleteModal(false);
|
|
},
|
|
});
|
|
|
|
// Filter templates
|
|
const filteredTemplates = useMemo(() => {
|
|
let result = templates;
|
|
|
|
// Filter by category
|
|
if (selectedCategory !== 'ALL') {
|
|
result = result.filter(t => t.category === selectedCategory);
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(t =>
|
|
t.name.toLowerCase().includes(query) ||
|
|
t.description.toLowerCase().includes(query) ||
|
|
t.subject.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}, [templates, selectedCategory, searchQuery]);
|
|
|
|
// Filter presets
|
|
const filteredPresets = useMemo(() => {
|
|
if (!presetsData?.presets) return [];
|
|
|
|
let allPresets: (TemplatePreset & { category: EmailTemplateCategory })[] = [];
|
|
|
|
// Flatten presets from all categories
|
|
Object.entries(presetsData.presets).forEach(([category, presets]) => {
|
|
allPresets.push(...presets.map(p => ({ ...p, category: category as EmailTemplateCategory })));
|
|
});
|
|
|
|
// Filter by category
|
|
if (selectedCategory !== 'ALL') {
|
|
allPresets = allPresets.filter(p => p.category === selectedCategory);
|
|
}
|
|
|
|
// Filter by style
|
|
if (selectedStyle !== 'all') {
|
|
allPresets = allPresets.filter(p => p.style === selectedStyle);
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
allPresets = allPresets.filter(p =>
|
|
p.name.toLowerCase().includes(query) ||
|
|
p.description.toLowerCase().includes(query) ||
|
|
p.subject.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return allPresets;
|
|
}, [presetsData, selectedCategory, selectedStyle, searchQuery]);
|
|
|
|
// Get available styles from all presets
|
|
const availableStyles = useMemo(() => {
|
|
if (!presetsData?.presets) return [];
|
|
const allPresets = Object.values(presetsData.presets).flat();
|
|
return Array.from(new Set(allPresets.map(p => p.style)));
|
|
}, [presetsData]);
|
|
|
|
const handleEdit = (template: EmailTemplate) => {
|
|
setEditingTemplate(template);
|
|
setShowCreateModal(true);
|
|
};
|
|
|
|
const handleDelete = (template: EmailTemplate) => {
|
|
setTemplateToDelete(template);
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
const handleDuplicate = (template: EmailTemplate) => {
|
|
duplicateMutation.mutate(template.id);
|
|
};
|
|
|
|
const handlePreview = (template: EmailTemplate) => {
|
|
setPreviewTemplate(template);
|
|
setShowPreviewModal(true);
|
|
};
|
|
|
|
const handleFormClose = () => {
|
|
setShowCreateModal(false);
|
|
setEditingTemplate(null);
|
|
};
|
|
|
|
const handleFormSuccess = () => {
|
|
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
|
handleFormClose();
|
|
};
|
|
|
|
// Selection handlers
|
|
const handleSelectTemplate = (templateId: string) => {
|
|
setSelectedTemplates(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(templateId)) {
|
|
next.delete(templateId);
|
|
} else {
|
|
next.add(templateId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectedTemplates.size === filteredTemplates.length) {
|
|
// Deselect all
|
|
setSelectedTemplates(new Set());
|
|
} else {
|
|
// Select all filtered templates
|
|
setSelectedTemplates(new Set(filteredTemplates.map(t => t.id)));
|
|
}
|
|
};
|
|
|
|
const handleBulkDelete = () => {
|
|
setShowBulkDeleteModal(true);
|
|
};
|
|
|
|
const confirmBulkDelete = () => {
|
|
bulkDeleteMutation.mutate(Array.from(selectedTemplates));
|
|
};
|
|
|
|
const handleUsePreset = (preset: TemplatePreset & { category: EmailTemplateCategory }) => {
|
|
// Create a new template from the preset
|
|
setEditingTemplate({
|
|
id: '',
|
|
name: preset.name,
|
|
description: preset.description,
|
|
subject: preset.subject,
|
|
htmlContent: preset.html_content,
|
|
textContent: preset.text_content,
|
|
category: preset.category,
|
|
scope: 'BUSINESS',
|
|
isDefault: false,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
} as EmailTemplate);
|
|
setShowCreateModal(true);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8">
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-8">
|
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
|
<p className="text-red-800 dark:text-red-300">
|
|
{t('common.error')}: {error instanceof Error ? error.message : 'Unknown error'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 space-y-6 max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<Mail className="h-7 w-7 text-brand-600" />
|
|
{t('emailTemplates.title', 'Email Templates')}
|
|
</h2>
|
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
|
{t('emailTemplates.description', 'Browse professional templates or create your own')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setEditingTemplate(null);
|
|
setShowCreateModal(true);
|
|
}}
|
|
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"
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
{t('emailTemplates.create', 'Create Template')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* View Tabs */}
|
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
|
<nav className="-mb-px flex space-x-8">
|
|
<button
|
|
onClick={() => setActiveView('browse')}
|
|
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
|
activeView === 'browse'
|
|
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
}`}
|
|
>
|
|
<Grid3x3 className="h-5 w-5" />
|
|
{t('emailTemplates.browseTemplates', 'Browse Templates')}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveView('my-templates')}
|
|
className={`flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
|
activeView === 'my-templates'
|
|
? 'border-brand-600 text-brand-600 dark:text-brand-400'
|
|
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600'
|
|
}`}
|
|
>
|
|
<List className="h-5 w-5" />
|
|
{t('emailTemplates.myTemplates', 'My Templates')} ({templates.length})
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 shadow-sm">
|
|
<div className="flex flex-col lg:flex-row gap-4">
|
|
{/* Search Bar */}
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t('emailTemplates.search', 'Search templates...')}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Category Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="h-5 w-5 text-gray-400" />
|
|
<select
|
|
value={selectedCategory}
|
|
onChange={(e) => setSelectedCategory(e.target.value as EmailTemplateCategory | 'ALL')}
|
|
className="px-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"
|
|
>
|
|
<option value="ALL">{t('emailTemplates.allCategories', 'All Categories')}</option>
|
|
<option value="APPOINTMENT">{t('emailTemplates.categoryAppointment', 'Appointment')}</option>
|
|
<option value="REMINDER">{t('emailTemplates.categoryReminder', 'Reminder')}</option>
|
|
<option value="CONFIRMATION">{t('emailTemplates.categoryConfirmation', 'Confirmation')}</option>
|
|
<option value="MARKETING">{t('emailTemplates.categoryMarketing', 'Marketing')}</option>
|
|
<option value="NOTIFICATION">{t('emailTemplates.categoryNotification', 'Notification')}</option>
|
|
<option value="REPORT">{t('emailTemplates.categoryReport', 'Report')}</option>
|
|
<option value="OTHER">{t('emailTemplates.categoryOther', 'Other')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Style Filter (Browse Mode Only) */}
|
|
{activeView === 'browse' && availableStyles.length > 0 && (
|
|
<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>
|
|
|
|
{/* Active Filters Summary */}
|
|
{(searchQuery || selectedCategory !== 'ALL' || selectedStyle !== 'all') && (
|
|
<div className="flex items-center gap-2 mt-4">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('emailTemplates.showing', 'Showing')} {activeView === 'browse' ? filteredPresets.length : filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
setSelectedCategory('ALL');
|
|
setSelectedStyle('all');
|
|
}}
|
|
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
|
>
|
|
{t('common.clearAll', 'Clear all')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Browse Templates View */}
|
|
{activeView === 'browse' && (
|
|
presetsLoading ? (
|
|
<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 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<Sparkles className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
|
{searchQuery || selectedCategory !== 'ALL' || selectedStyle !== 'all'
|
|
? t('emailTemplates.noPresets', 'No templates found matching your criteria')
|
|
: t('emailTemplates.noPresetsAvailable', 'No templates available')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filteredPresets.map((preset, index) => (
|
|
<div
|
|
key={index}
|
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden shadow-sm hover:shadow-lg transition-all cursor-pointer group"
|
|
>
|
|
{/* Preview */}
|
|
<div className="h-56 bg-gray-50 dark:bg-gray-700 relative overflow-hidden">
|
|
<div className="absolute inset-0">
|
|
<iframe
|
|
srcDoc={preset.html_content}
|
|
className="w-full h-full pointer-events-none"
|
|
title={preset.name}
|
|
sandbox="allow-same-origin"
|
|
/>
|
|
</div>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-end justify-center pb-6 gap-2">
|
|
<button
|
|
onClick={() => setPreviewPreset(preset)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-white/95 dark:bg-gray-800/95 text-gray-900 dark:text-white rounded-lg text-sm font-medium hover:bg-white dark:hover:bg-gray-800"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
Preview
|
|
</button>
|
|
<button
|
|
onClick={() => handleUsePreset(preset)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Use Template
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-5">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<h4 className="text-base font-semibold text-gray-900 dark:text-white line-clamp-1 flex-1">
|
|
{preset.name}
|
|
</h4>
|
|
</div>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[preset.category]}`}>
|
|
{categoryIcons[preset.category]}
|
|
{preset.category}
|
|
</span>
|
|
<span className={`inline-flex items-center gap-1 px-2.5 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-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
{preset.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* My Templates List */}
|
|
{activeView === 'my-templates' && (filteredTemplates.length === 0 ? (
|
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<Mail className="h-12 w-12 mx-auto text-gray-400 mb-4" />
|
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
|
{searchQuery || selectedCategory !== 'ALL'
|
|
? t('emailTemplates.noResults', 'No templates found matching your criteria')
|
|
: t('emailTemplates.empty', 'No email templates yet')}
|
|
</p>
|
|
{!searchQuery && selectedCategory === 'ALL' && (
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{t('emailTemplates.createFirst', 'Create your first template')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Bulk Actions Bar */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
{/* Select All Checkbox */}
|
|
<button
|
|
onClick={handleSelectAll}
|
|
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"
|
|
>
|
|
{selectedTemplates.size === filteredTemplates.length && filteredTemplates.length > 0 ? (
|
|
<CheckSquare className="h-5 w-5 text-brand-600" />
|
|
) : selectedTemplates.size > 0 ? (
|
|
<div className="relative">
|
|
<Square className="h-5 w-5" />
|
|
<Minus className="h-3 w-3 absolute top-1 left-1 text-brand-600" />
|
|
</div>
|
|
) : (
|
|
<Square className="h-5 w-5" />
|
|
)}
|
|
<span>
|
|
{selectedTemplates.size === 0
|
|
? t('emailTemplates.selectAll', 'Select All')
|
|
: selectedTemplates.size === filteredTemplates.length
|
|
? t('emailTemplates.deselectAll', 'Deselect All')
|
|
: t('emailTemplates.selectedCount', '{{count}} selected', { count: selectedTemplates.size })}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Bulk Delete Button */}
|
|
{selectedTemplates.size > 0 && (
|
|
<button
|
|
onClick={handleBulkDelete}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t('emailTemplates.deleteSelected', 'Delete Selected')} ({selectedTemplates.size})
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{filteredTemplates.map((template) => (
|
|
<div
|
|
key={template.id}
|
|
className={`bg-white dark:bg-gray-800 rounded-xl border shadow-sm hover:shadow-md transition-shadow overflow-hidden ${
|
|
selectedTemplates.has(template.id)
|
|
? 'border-brand-500 ring-2 ring-brand-500/20'
|
|
: 'border-gray-200 dark:border-gray-700'
|
|
}`}
|
|
>
|
|
<div className="flex">
|
|
{/* Checkbox */}
|
|
<div className="flex items-center justify-center w-12 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
|
|
<button
|
|
onClick={() => handleSelectTemplate(template.id)}
|
|
className="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
|
>
|
|
{selectedTemplates.has(template.id) ? (
|
|
<CheckSquare className="h-5 w-5 text-brand-600" />
|
|
) : (
|
|
<Square className="h-5 w-5 text-gray-400" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Preview */}
|
|
<div className="w-48 h-32 bg-gray-50 dark:bg-gray-700 relative overflow-hidden flex-shrink-0">
|
|
{template.htmlContent ? (
|
|
<div className="absolute inset-0">
|
|
<iframe
|
|
srcDoc={template.htmlContent}
|
|
className="w-full h-full pointer-events-none"
|
|
title={template.name}
|
|
sandbox="allow-same-origin"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-center w-full h-full text-gray-400 text-xs">
|
|
No HTML Content
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-6 flex-1">
|
|
<div className="flex items-start justify-between">
|
|
{/* Template Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{template.name}
|
|
</h3>
|
|
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${categoryColors[template.category]}`}>
|
|
{categoryIcons[template.category]}
|
|
{template.category}
|
|
</span>
|
|
</div>
|
|
|
|
{template.description && (
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
{template.description}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-sm text-gray-500 dark:text-gray-500 mb-3">
|
|
<span className="font-medium">{t('emailTemplates.subject', 'Subject')}:</span> {template.subject}
|
|
</p>
|
|
|
|
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
|
<div className="flex items-center gap-1">
|
|
<span className="font-medium">
|
|
{t('emailTemplates.updatedAt', 'Updated')}:
|
|
</span>
|
|
<span>
|
|
{new Date(template.updatedAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
{template.htmlContent && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300">
|
|
HTML
|
|
</span>
|
|
)}
|
|
{template.textContent && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
|
Text
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 ml-4">
|
|
<button
|
|
onClick={() => handlePreview(template)}
|
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
title={t('emailTemplates.preview', 'Preview')}
|
|
>
|
|
<Eye className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDuplicate(template)}
|
|
disabled={duplicateMutation.isPending}
|
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
|
title={t('emailTemplates.duplicate', 'Duplicate')}
|
|
>
|
|
<Copy className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleEdit(template)}
|
|
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
title={t('common.edit', 'Edit')}
|
|
>
|
|
<Edit2 className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(template)}
|
|
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
|
title={t('common.delete', 'Delete')}
|
|
>
|
|
<Trash2 className="h-5 w-5" />
|
|
</button>
|
|
</div> {/* Closes flex items-start justify-between */}
|
|
</div> {/* Closes p-6 flex-1 */}
|
|
</div> {/* Closes flex */}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
|
|
{/* Preset Preview Modal */}
|
|
{previewPreset && (
|
|
<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-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">
|
|
{previewPreset.name}
|
|
</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
{previewPreset.description}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setPreviewPreset(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">
|
|
{previewPreset.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={previewPreset.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={() => setPreviewPreset(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={() => {
|
|
handleUsePreset(previewPreset as TemplatePreset & { category: EmailTemplateCategory });
|
|
setPreviewPreset(null);
|
|
}}
|
|
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"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Use This Template
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Edit Modal */}
|
|
{showCreateModal && (
|
|
<EmailTemplateForm
|
|
template={editingTemplate}
|
|
onClose={handleFormClose}
|
|
onSuccess={handleFormSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteModal && templateToDelete && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
|
{/* Modal Header */}
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
|
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{t('emailTemplates.confirmDelete', 'Delete Template')}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setShowDeleteModal(false);
|
|
setTemplateToDelete(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>
|
|
|
|
{/* Modal Body */}
|
|
<div className="p-6">
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
{t('emailTemplates.deleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{templateToDelete.name}</span>?
|
|
</p>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using this template may no longer work correctly.')}
|
|
</p>
|
|
</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={() => {
|
|
setShowDeleteModal(false);
|
|
setTemplateToDelete(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"
|
|
>
|
|
{t('common.cancel', 'Cancel')}
|
|
</button>
|
|
<button
|
|
onClick={() => deleteMutation.mutate(templateToDelete.id)}
|
|
disabled={deleteMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
|
>
|
|
{deleteMutation.isPending ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
{t('common.deleting', 'Deleting...')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t('common.delete', 'Delete')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview Modal */}
|
|
{showPreviewModal && previewTemplate && (
|
|
<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-4xl w-full max-h-[90vh] 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">
|
|
{t('emailTemplates.preview', 'Preview')}: {previewTemplate.name}
|
|
</h3>
|
|
<button
|
|
onClick={() => {
|
|
setShowPreviewModal(false);
|
|
setPreviewTemplate(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>
|
|
|
|
{/* Modal Body */}
|
|
<div className="p-6 overflow-y-auto flex-1">
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('emailTemplates.subject', 'Subject')}
|
|
</label>
|
|
<div className="p-3 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white">
|
|
{previewTemplate.subject}
|
|
</div>
|
|
</div>
|
|
|
|
{previewTemplate.htmlContent && (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('emailTemplates.htmlPreview', 'HTML Preview')}
|
|
</label>
|
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
<iframe
|
|
srcDoc={previewTemplate.htmlContent}
|
|
className="w-full h-96 bg-white"
|
|
title="Email Preview"
|
|
sandbox="allow-same-origin"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{previewTemplate.textContent && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('emailTemplates.textPreview', 'Plain Text Preview')}
|
|
</label>
|
|
<pre className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-900 dark:text-white text-sm whitespace-pre-wrap font-mono overflow-auto max-h-48">
|
|
{previewTemplate.textContent}
|
|
</pre>
|
|
</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={() => {
|
|
setShowPreviewModal(false);
|
|
setPreviewTemplate(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"
|
|
>
|
|
{t('common.close', 'Close')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setShowPreviewModal(false);
|
|
handleEdit(previewTemplate);
|
|
}}
|
|
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"
|
|
>
|
|
<Edit2 className="h-4 w-4" />
|
|
{t('common.edit', 'Edit')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bulk Delete Confirmation Modal */}
|
|
{showBulkDeleteModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
|
|
{/* Modal Header */}
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
|
|
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{t('emailTemplates.confirmBulkDelete', 'Delete Templates')}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowBulkDeleteModal(false)}
|
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Body */}
|
|
<div className="p-6">
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
|
{t('emailTemplates.bulkDeleteWarning', 'Are you sure you want to delete')} <span className="font-semibold text-gray-900 dark:text-white">{selectedTemplates.size} {t('emailTemplates.templates', 'templates')}</span>?
|
|
</p>
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3 max-h-32 overflow-y-auto">
|
|
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
|
{filteredTemplates
|
|
.filter(t => selectedTemplates.has(t.id))
|
|
.map(t => (
|
|
<li key={t.id} className="flex items-center gap-2">
|
|
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full"></span>
|
|
{t.name}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-4">
|
|
{t('emailTemplates.deleteNote', 'This action cannot be undone. Plugins using these templates may no longer work correctly.')}
|
|
</p>
|
|
</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={() => setShowBulkDeleteModal(false)}
|
|
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={confirmBulkDelete}
|
|
disabled={bulkDeleteMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
|
>
|
|
{bulkDeleteMutation.isPending ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
{t('common.deleting', 'Deleting...')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Trash2 className="h-4 w-4" />
|
|
{t('emailTemplates.deleteAll', 'Delete All')} ({selectedTemplates.size})
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EmailTemplates;
|