- 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>
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
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;
|