forked from jamesp/sasa-membership
d024bf7fa3
- ui has been made 'kinda better' (after making it worse for a while lol - ESP rfid readers are now supported [ill upload the code for them in another repo later] - admin system has been secured a bit better and seems to be working well
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import axios from 'axios';
|
||
import { useToast } from '../contexts/ToastContext';
|
||
|
||
interface EmailTemplate {
|
||
template_key: string;
|
||
name: string;
|
||
subject: string;
|
||
html_body: string;
|
||
text_body: string;
|
||
variables: string;
|
||
is_active: boolean;
|
||
}
|
||
|
||
interface EmailTemplateManagementProps {
|
||
searchTerm?: string;
|
||
statusFilter?: 'all' | 'active' | 'inactive';
|
||
refreshToken?: number;
|
||
}
|
||
|
||
type TemplateSortKey = 'name' | 'key' | 'subject' | 'variables' | 'status';
|
||
|
||
const parseTemplateVariables = (variables: string): string[] => {
|
||
try {
|
||
const parsed = JSON.parse(variables);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return variables
|
||
.split(',')
|
||
.map((value) => value.trim())
|
||
.filter(Boolean);
|
||
}
|
||
};
|
||
|
||
const EmailTemplateManagement: React.FC<EmailTemplateManagementProps> = ({
|
||
searchTerm = '',
|
||
statusFilter = 'all',
|
||
refreshToken = 0
|
||
}) => {
|
||
const toast = useToast();
|
||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||
const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
|
||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||
|
||
useEffect(() => {
|
||
void fetchTemplates();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (refreshToken > 0) {
|
||
void fetchTemplates();
|
||
}
|
||
}, [refreshToken]);
|
||
|
||
const fetchTemplates = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const token = localStorage.getItem('token');
|
||
const response = await axios.get('/api/v1/email-templates/', {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
setTemplates(response.data);
|
||
} catch (error) {
|
||
console.error('Error fetching email templates:', error);
|
||
toast.error('Failed to load email templates.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
|
||
try {
|
||
const token = localStorage.getItem('token');
|
||
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
|
||
headers: { Authorization: `Bearer ${token}` }
|
||
});
|
||
setEditingTemplate(null);
|
||
toast.success('Email template updated.');
|
||
void fetchTemplates();
|
||
} catch (error) {
|
||
console.error('Error updating email template:', error);
|
||
toast.error('Failed to update email template.');
|
||
}
|
||
};
|
||
|
||
const filteredTemplates = useMemo(() => {
|
||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||
|
||
return templates.filter((template) => {
|
||
const matchesSearch = normalizedSearch === '' || [
|
||
template.name,
|
||
template.template_key,
|
||
template.subject
|
||
].some((value) => value.toLowerCase().includes(normalizedSearch));
|
||
|
||
const matchesStatus =
|
||
statusFilter === 'all' ||
|
||
(statusFilter === 'active' && template.is_active) ||
|
||
(statusFilter === 'inactive' && !template.is_active);
|
||
|
||
return matchesSearch && matchesStatus;
|
||
});
|
||
}, [searchTerm, statusFilter, templates]);
|
||
|
||
const sortedTemplates = useMemo(() => {
|
||
const compareValues = (left: string | number, right: string | number) => {
|
||
if (typeof left === 'number' && typeof right === 'number') return left - right;
|
||
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
|
||
};
|
||
|
||
const sorted = [...filteredTemplates].sort((left, right) => {
|
||
let result = 0;
|
||
|
||
switch (sortKey) {
|
||
case 'name':
|
||
result = compareValues(left.name, right.name);
|
||
break;
|
||
case 'key':
|
||
result = compareValues(left.template_key, right.template_key);
|
||
break;
|
||
case 'subject':
|
||
result = compareValues(left.subject, right.subject);
|
||
break;
|
||
case 'variables':
|
||
result = compareValues(parseTemplateVariables(left.variables).length, parseTemplateVariables(right.variables).length);
|
||
break;
|
||
case 'status':
|
||
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
|
||
break;
|
||
}
|
||
|
||
if (result === 0) {
|
||
result = compareValues(left.name, right.name);
|
||
}
|
||
|
||
return sortDirection === 'asc' ? result : -result;
|
||
});
|
||
|
||
return sorted;
|
||
}, [filteredTemplates, sortDirection, sortKey]);
|
||
|
||
const toggleSort = (nextKey: TemplateSortKey) => {
|
||
if (sortKey === nextKey) {
|
||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||
return;
|
||
}
|
||
setSortKey(nextKey);
|
||
setSortDirection('asc');
|
||
};
|
||
|
||
const renderSortArrow = (active: boolean, direction: 'asc' | 'desc') => (
|
||
<span className={`admin-sort-arrow ${active ? 'active' : ''} ${direction}`}>
|
||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||
<path d="M4 10.5 8 6l4 4.5" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
|
||
if (loading) {
|
||
return <p className="admin-empty">Loading email templates...</p>;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="admin-table-shell">
|
||
<div className="admin-table-wrap">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>
|
||
<button type="button" className={sortKey === 'name' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('name')}>
|
||
<span>Template</span>{renderSortArrow(sortKey === 'name', sortDirection)}
|
||
</button>
|
||
</th>
|
||
<th>
|
||
<button type="button" className={sortKey === 'key' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('key')}>
|
||
<span>Key</span>{renderSortArrow(sortKey === 'key', sortDirection)}
|
||
</button>
|
||
</th>
|
||
<th>
|
||
<button type="button" className={sortKey === 'subject' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('subject')}>
|
||
<span>Subject</span>{renderSortArrow(sortKey === 'subject', sortDirection)}
|
||
</button>
|
||
</th>
|
||
<th>
|
||
<button type="button" className={sortKey === 'variables' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('variables')}>
|
||
<span>Variables</span>{renderSortArrow(sortKey === 'variables', sortDirection)}
|
||
</button>
|
||
</th>
|
||
<th>
|
||
<button type="button" className={sortKey === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||
</button>
|
||
</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sortedTemplates.length === 0 ? (
|
||
<tr>
|
||
<td className="admin-table-empty" colSpan={6}>No templates match the current filters.</td>
|
||
</tr>
|
||
) : (
|
||
sortedTemplates.map((template) => {
|
||
const variables = parseTemplateVariables(template.variables);
|
||
return (
|
||
<tr key={template.template_key} onClick={() => setEditingTemplate(template)}>
|
||
<td>
|
||
<strong>{template.name}</strong>
|
||
<div className="muted-line">{variables.length} variable{variables.length === 1 ? '' : 's'}</div>
|
||
</td>
|
||
<td>
|
||
<code>{template.template_key}</code>
|
||
</td>
|
||
<td>{template.subject}</td>
|
||
<td>
|
||
{variables.length > 0 ? (
|
||
<div className="admin-inline-list">
|
||
{variables.slice(0, 3).map((variable) => (
|
||
<span key={variable} className="admin-inline-chip">{variable}</span>
|
||
))}
|
||
{variables.length > 3 && <span className="muted-line">+{variables.length - 3} more</span>}
|
||
</div>
|
||
) : (
|
||
<span className="muted-line">None</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<span className={`status-badge ${template.is_active ? 'status-active' : 'status-expired'}`}>
|
||
{template.is_active ? 'Active' : 'Inactive'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<div className="table-button-row">
|
||
<button
|
||
className="btn btn-primary"
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
setEditingTemplate(template);
|
||
}}
|
||
>
|
||
Edit
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{editingTemplate && (
|
||
<EmailTemplateEditForm
|
||
template={editingTemplate}
|
||
onSave={handleSaveTemplate}
|
||
onCancel={() => setEditingTemplate(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
interface EmailTemplateEditFormProps {
|
||
template: EmailTemplate;
|
||
onSave: (template: EmailTemplate) => void;
|
||
onCancel: () => void;
|
||
}
|
||
|
||
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
|
||
const [previewMode, setPreviewMode] = useState<'rendered' | 'html' | 'text'>('rendered');
|
||
const [formData, setFormData] = useState({
|
||
name: template.name,
|
||
subject: template.subject,
|
||
html_body: template.html_body,
|
||
text_body: template.text_body,
|
||
variables: (() => {
|
||
try {
|
||
const vars = JSON.parse(template.variables);
|
||
return Array.isArray(vars) ? vars : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
})(),
|
||
is_active: template.is_active
|
||
});
|
||
|
||
const handleChange = (field: keyof typeof formData, value: any) => {
|
||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||
};
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
onSave({
|
||
template_key: template.template_key,
|
||
...formData,
|
||
variables: JSON.stringify(formData.variables)
|
||
});
|
||
};
|
||
|
||
const previewDocument = useMemo(() => {
|
||
return `
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<style>
|
||
body {
|
||
margin: 0;
|
||
padding: 24px;
|
||
background: #ffffff;
|
||
color: #111111;
|
||
font-family: Arial, sans-serif;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
${formData.html_body}
|
||
</body>
|
||
</html>
|
||
`;
|
||
}, [formData.html_body]);
|
||
|
||
return (
|
||
<div className="drawer-overlay" onClick={onCancel}>
|
||
<aside className="user-drawer property-drawer" onClick={(event) => event.stopPropagation()}>
|
||
<div className="drawer-header">
|
||
<div className="drawer-header-main">
|
||
<span className="drawer-eyebrow">Template Editor</span>
|
||
<h3>Edit Email Template</h3>
|
||
<p>{template.name}</p>
|
||
</div>
|
||
<div className="drawer-header-actions">
|
||
<button className="drawer-close" type="button" onClick={onCancel}>×</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="drawer-hero">
|
||
<div className="drawer-hero-grid">
|
||
<div className="drawer-hero-card">
|
||
<span className="drawer-hero-label">Key</span>
|
||
<span className="drawer-hero-value">{template.template_key}</span>
|
||
</div>
|
||
<div className="drawer-hero-card">
|
||
<span className="drawer-hero-label">Status</span>
|
||
<span className="drawer-hero-value">
|
||
<span className={`status-badge ${formData.is_active ? 'status-active' : 'status-expired'}`}>
|
||
{formData.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="drawer-body">
|
||
<div className="drawer-section">
|
||
<div className="drawer-section-header">
|
||
<div>
|
||
<h4>Template Content</h4>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-group">
|
||
<label>Template Key</label>
|
||
<input type="text" value={template.template_key} disabled className="admin-field admin-field-disabled" />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Name</label>
|
||
<input type="text" value={formData.name} onChange={(e) => handleChange('name', e.target.value)} className="admin-field" required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Subject</label>
|
||
<input type="text" value={formData.subject} onChange={(e) => handleChange('subject', e.target.value)} className="admin-field" required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Variables</label>
|
||
<input
|
||
type="text"
|
||
value={formData.variables.join(', ')}
|
||
onChange={(e) => handleChange('variables', e.target.value.split(',').map((v) => v.trim()).filter(Boolean))}
|
||
className="admin-field"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>HTML Body</label>
|
||
<textarea
|
||
value={formData.html_body}
|
||
onChange={(e) => handleChange('html_body', e.target.value)}
|
||
rows={15}
|
||
className="admin-field admin-field-textarea admin-code-textarea"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Text Body</label>
|
||
<textarea
|
||
value={formData.text_body}
|
||
onChange={(e) => handleChange('text_body', e.target.value)}
|
||
rows={10}
|
||
className="admin-field admin-field-textarea admin-code-textarea"
|
||
required
|
||
/>
|
||
</div>
|
||
<label className="admin-switch-row" style={{ marginBottom: '20px' }}>
|
||
<input type="checkbox" checked={formData.is_active} onChange={(e) => handleChange('is_active', e.target.checked)} />
|
||
Active
|
||
</label>
|
||
<div className="table-button-row" style={{ justifyContent: 'flex-end' }}>
|
||
<button type="button" className="btn btn-secondary" onClick={onCancel}>Cancel</button>
|
||
<button type="submit" className="btn btn-primary">Save Changes</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div className="drawer-section">
|
||
<div className="drawer-section-header">
|
||
<div>
|
||
<h4>Preview</h4>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="email-preview-tabs" role="tablist" aria-label="Email preview mode">
|
||
<button
|
||
type="button"
|
||
className={previewMode === 'rendered' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||
onClick={() => setPreviewMode('rendered')}
|
||
>
|
||
Rendered
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={previewMode === 'html' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||
onClick={() => setPreviewMode('html')}
|
||
>
|
||
HTML
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={previewMode === 'text' ? 'email-preview-tab active' : 'email-preview-tab'}
|
||
onClick={() => setPreviewMode('text')}
|
||
>
|
||
Text
|
||
</button>
|
||
</div>
|
||
|
||
{previewMode === 'rendered' && (
|
||
<div className="email-preview-frame-shell">
|
||
<iframe
|
||
title={`${template.name} preview`}
|
||
className="email-preview-frame"
|
||
srcDoc={previewDocument}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{previewMode === 'html' && (
|
||
<pre className="email-preview-code">{formData.html_body}</pre>
|
||
)}
|
||
|
||
{previewMode === 'text' && (
|
||
<pre className="email-preview-code">{formData.text_body}</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default EmailTemplateManagement;
|