441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
/**
|
|
* @ai-summary Admin Email Templates mobile screen for managing notification email templates
|
|
* @ai-context Mobile-optimized version with touch-friendly UI
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { Navigate } from 'react-router-dom';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Edit, Visibility, Close, Send } from '@mui/icons-material';
|
|
import toast from 'react-hot-toast';
|
|
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
|
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
|
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
|
import { adminApi } from '../api/admin.api';
|
|
import { EmailTemplate, UpdateEmailTemplateRequest } from '../types/admin.types';
|
|
|
|
const SAMPLE_VARIABLES: Record<string, string> = {
|
|
userName: 'John Doe',
|
|
vehicleName: '2024 Toyota Camry',
|
|
category: 'Routine Maintenance',
|
|
subtypes: 'Oil Change, Air Filter',
|
|
dueDate: '2025-01-15',
|
|
dueMileage: '50,000',
|
|
documentType: 'Insurance',
|
|
documentTitle: 'State Farm Policy',
|
|
expirationDate: '2025-02-28',
|
|
};
|
|
|
|
export const AdminEmailTemplatesMobileScreen: React.FC = () => {
|
|
const { loading: authLoading, isAdmin } = useAdminAccess();
|
|
const queryClient = useQueryClient();
|
|
|
|
// State
|
|
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
|
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplate | null>(null);
|
|
const [editSubject, setEditSubject] = useState('');
|
|
const [editBody, setEditBody] = useState('');
|
|
const [editIsActive, setEditIsActive] = useState(true);
|
|
const [previewSubject, setPreviewSubject] = useState('');
|
|
const [previewBody, setPreviewBody] = useState('');
|
|
|
|
// Queries
|
|
const { data: templates, isLoading } = useQuery({
|
|
queryKey: ['admin', 'emailTemplates'],
|
|
queryFn: () => adminApi.emailTemplates.list(),
|
|
enabled: isAdmin,
|
|
});
|
|
|
|
// Mutations
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ key, data }: { key: string; data: UpdateEmailTemplateRequest }) =>
|
|
adminApi.emailTemplates.update(key, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['admin', 'emailTemplates'] });
|
|
toast.success('Template updated');
|
|
handleCloseEdit();
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to update template');
|
|
},
|
|
});
|
|
|
|
const previewMutation = useMutation({
|
|
mutationFn: ({ key, variables }: { key: string; variables: Record<string, string> }) =>
|
|
adminApi.emailTemplates.preview(key, variables),
|
|
onSuccess: (data) => {
|
|
setPreviewSubject(data.subject);
|
|
setPreviewBody(data.body);
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to generate preview');
|
|
},
|
|
});
|
|
|
|
const sendTestMutation = useMutation({
|
|
mutationFn: (key: string) => adminApi.emailTemplates.sendTest(key),
|
|
onSuccess: (data) => {
|
|
if (data.error) {
|
|
toast.error(`Test email failed: ${data.error}`);
|
|
} else if (data.message) {
|
|
toast.success(data.message);
|
|
}
|
|
},
|
|
onError: () => {
|
|
toast.error('Failed to send test email');
|
|
},
|
|
});
|
|
|
|
// Handlers
|
|
const handleEditClick = useCallback((template: EmailTemplate) => {
|
|
setEditingTemplate(template);
|
|
setEditSubject(template.subject);
|
|
setEditBody(template.body);
|
|
setEditIsActive(template.isActive);
|
|
}, []);
|
|
|
|
const handlePreviewClick = useCallback((template: EmailTemplate) => {
|
|
setPreviewTemplate(template);
|
|
previewMutation.mutate({
|
|
key: template.templateKey,
|
|
variables: SAMPLE_VARIABLES,
|
|
});
|
|
}, [previewMutation]);
|
|
|
|
const handleSendTestClick = useCallback((template: EmailTemplate) => {
|
|
sendTestMutation.mutate(template.templateKey);
|
|
}, [sendTestMutation]);
|
|
|
|
const handleCloseEdit = useCallback(() => {
|
|
setEditingTemplate(null);
|
|
setEditSubject('');
|
|
setEditBody('');
|
|
setEditIsActive(true);
|
|
}, []);
|
|
|
|
const handleClosePreview = useCallback(() => {
|
|
setPreviewTemplate(null);
|
|
setPreviewSubject('');
|
|
setPreviewBody('');
|
|
}, []);
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (!editingTemplate) return;
|
|
|
|
const data: UpdateEmailTemplateRequest = {
|
|
subject: editSubject !== editingTemplate.subject ? editSubject : undefined,
|
|
body: editBody !== editingTemplate.body ? editBody : undefined,
|
|
isActive: editIsActive !== editingTemplate.isActive ? editIsActive : undefined,
|
|
};
|
|
|
|
// Only update if there are changes
|
|
if (data.subject || data.body || data.isActive !== undefined) {
|
|
updateMutation.mutate({
|
|
key: editingTemplate.templateKey,
|
|
data,
|
|
});
|
|
} else {
|
|
handleCloseEdit();
|
|
}
|
|
}, [editingTemplate, editSubject, editBody, editIsActive, updateMutation, handleCloseEdit]);
|
|
|
|
// Auth loading
|
|
if (authLoading) {
|
|
return (
|
|
<MobileContainer>
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="text-center">
|
|
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
|
</div>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
}
|
|
|
|
// Not admin
|
|
if (!isAdmin) {
|
|
return <Navigate to="/garage/settings" replace />;
|
|
}
|
|
|
|
// Edit view
|
|
if (editingTemplate) {
|
|
return (
|
|
<MobileContainer>
|
|
<div className="space-y-4 pb-24 p-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-slate-800">Edit Template</h1>
|
|
<p className="text-sm text-slate-500">{editingTemplate.name}</p>
|
|
</div>
|
|
<button
|
|
onClick={handleCloseEdit}
|
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
|
>
|
|
<Close />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Edit Form */}
|
|
<GlassCard>
|
|
<div className="p-4 space-y-4">
|
|
{/* Active Toggle */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-slate-700">Active</span>
|
|
<button
|
|
onClick={() => setEditIsActive(!editIsActive)}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors min-h-[44px] min-w-[44px] ${
|
|
editIsActive ? 'bg-blue-600' : 'bg-gray-200'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
editIsActive ? 'translate-x-6' : 'translate-x-1'
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Subject */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Subject
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editSubject}
|
|
onChange={(e) => setEditSubject(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[44px]"
|
|
placeholder="Email subject"
|
|
/>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Body
|
|
</label>
|
|
<textarea
|
|
value={editBody}
|
|
onChange={(e) => setEditBody(e.target.value)}
|
|
rows={12}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
|
placeholder="Email body"
|
|
/>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Use variables like {editingTemplate.variables.map((v) => `{{${v}}}`).join(', ')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Available Variables */}
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<p className="text-xs font-medium text-blue-900 mb-2">Available Variables</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{editingTemplate.variables.map((variable) => (
|
|
<span
|
|
key={variable}
|
|
className="inline-block px-2 py-1 bg-white border border-blue-300 rounded text-xs font-mono text-blue-700"
|
|
>
|
|
{`{{${variable}}}`}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
onClick={handleCloseEdit}
|
|
className="flex-1 px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={updateMutation.isPending}
|
|
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:bg-gray-300 min-h-[44px]"
|
|
>
|
|
{updateMutation.isPending ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
}
|
|
|
|
// Preview view
|
|
if (previewTemplate) {
|
|
return (
|
|
<MobileContainer>
|
|
<div className="space-y-4 pb-24 p-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-slate-800">Preview</h1>
|
|
<p className="text-sm text-slate-500">{previewTemplate.name}</p>
|
|
</div>
|
|
<button
|
|
onClick={handleClosePreview}
|
|
className="p-2 hover:bg-gray-100 rounded-full transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
|
|
>
|
|
<Close />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Preview Content */}
|
|
<GlassCard>
|
|
<div className="p-4 space-y-4">
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<p className="text-xs text-blue-700">
|
|
This preview uses sample data to show how the template will appear.
|
|
</p>
|
|
</div>
|
|
|
|
{previewMutation.isPending ? (
|
|
<div className="text-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
|
<p className="text-slate-500 mt-2 text-sm">Generating preview...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Subject
|
|
</label>
|
|
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg min-h-[44px] flex items-center">
|
|
{previewSubject}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
Body
|
|
</label>
|
|
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-lg font-mono text-sm whitespace-pre-wrap">
|
|
{previewBody}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleClosePreview}
|
|
className="w-full px-4 py-3 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</GlassCard>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
}
|
|
|
|
// List view
|
|
return (
|
|
<MobileContainer>
|
|
<div className="space-y-4 pb-24 p-4">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-800">Email Templates</h1>
|
|
<p className="text-sm text-slate-500">
|
|
{templates?.length || 0} notification templates
|
|
</p>
|
|
</div>
|
|
|
|
{/* Templates List */}
|
|
{isLoading ? (
|
|
<GlassCard>
|
|
<div className="p-8 text-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
|
<p className="text-slate-500 mt-2 text-sm">Loading templates...</p>
|
|
</div>
|
|
</GlassCard>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{templates?.map((template) => (
|
|
<GlassCard key={template.id}>
|
|
<div className="p-4">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-slate-800">{template.name}</h3>
|
|
{template.description && (
|
|
<p className="text-xs text-slate-500 mt-1">{template.description}</p>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${
|
|
template.isActive
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}
|
|
>
|
|
{template.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Subject */}
|
|
<div className="mb-3">
|
|
<p className="text-xs font-medium text-slate-600 mb-1">Subject</p>
|
|
<p className="text-sm text-slate-700 truncate">{template.subject}</p>
|
|
</div>
|
|
|
|
{/* Variables */}
|
|
<div className="mb-4">
|
|
<p className="text-xs font-medium text-slate-600 mb-1">Variables</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{template.variables.map((variable) => (
|
|
<span
|
|
key={variable}
|
|
className="inline-block px-2 py-1 bg-gray-100 border border-gray-300 rounded text-xs font-mono text-slate-600"
|
|
>
|
|
{`{{${variable}}}`}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => handleEditClick(template)}
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors min-h-[44px]"
|
|
>
|
|
<Edit fontSize="small" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
onClick={() => handlePreviewClick(template)}
|
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors min-h-[44px]"
|
|
>
|
|
<Visibility fontSize="small" />
|
|
Preview
|
|
</button>
|
|
<button
|
|
onClick={() => handleSendTestClick(template)}
|
|
disabled={sendTestMutation.isPending}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors disabled:bg-gray-300 disabled:text-gray-500 min-h-[44px]"
|
|
>
|
|
<Send fontSize="small" />
|
|
{sendTestMutation.isPending ? 'Sending...' : 'Send Test Email'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
};
|
|
|
|
export default AdminEmailTemplatesMobileScreen;
|