feat: Email templates, bulk delete, communication credits, plan features
- 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>
This commit is contained in:
@@ -18,7 +18,15 @@ import {
|
||||
FileText,
|
||||
BarChart3,
|
||||
Package,
|
||||
AlertTriangle
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Smile,
|
||||
Minus,
|
||||
Grid3x3,
|
||||
List,
|
||||
Check,
|
||||
Square,
|
||||
CheckSquare
|
||||
} from 'lucide-react';
|
||||
import api from '../api/client';
|
||||
import { EmailTemplate, EmailTemplateCategory } from '../types';
|
||||
@@ -37,26 +45,52 @@ const categoryIcons: Record<EmailTemplateCategory, React.ReactNode> = {
|
||||
|
||||
// Category colors
|
||||
const categoryColors: Record<EmailTemplateCategory, string> = {
|
||||
APPOINTMENT: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
REMINDER: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
CONFIRMATION: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
MARKETING: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
NOTIFICATION: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300',
|
||||
REPORT: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
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[]>({
|
||||
@@ -82,6 +116,15 @@ const EmailTemplates: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
@@ -105,6 +148,19 @@ const EmailTemplates: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -127,6 +183,47 @@ const EmailTemplates: React.FC = () => {
|
||||
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);
|
||||
@@ -156,6 +253,55 @@ const EmailTemplates: React.FC = () => {
|
||||
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">
|
||||
@@ -188,11 +334,14 @@ const EmailTemplates: React.FC = () => {
|
||||
{t('emailTemplates.title', 'Email Templates')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('emailTemplates.description', 'Create and manage reusable email templates for your plugins')}
|
||||
{t('emailTemplates.description', 'Browse professional templates or create your own')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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" />
|
||||
@@ -200,6 +349,34 @@ const EmailTemplates: React.FC = () => {
|
||||
</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">
|
||||
@@ -233,31 +410,137 @@ const EmailTemplates: React.FC = () => {
|
||||
<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') && (
|
||||
{(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')} {filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
||||
{t('emailTemplates.showing', 'Showing')} {activeView === 'browse' ? filteredPresets.length : filteredTemplates.length} {t('emailTemplates.results', 'results')}
|
||||
</span>
|
||||
{(searchQuery || selectedCategory !== 'ALL') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedCategory('ALL');
|
||||
}}
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear all')}
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Templates List */}
|
||||
{filteredTemplates.length === 0 ? (
|
||||
{/* 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">
|
||||
@@ -277,15 +560,92 @@ const EmailTemplates: React.FC = () => {
|
||||
</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 border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
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="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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}
|
||||
@@ -359,11 +719,80 @@ const EmailTemplates: React.FC = () => {
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -528,6 +957,80 @@ const EmailTemplates: React.FC = () => {
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user