Files
smoothschedule/frontend/src/components/EmailTemplatePresetSelector.tsx
poduck 05ebd0f2bb 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>
2025-12-02 01:42:38 -05:00

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;