Email template management
This commit is contained in:
@@ -5,6 +5,8 @@ import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import EmailTemplates from './pages/EmailTemplates';
|
||||
import MembershipTiers from './pages/MembershipTiers';
|
||||
import './App.css';
|
||||
|
||||
const App: React.FC = () => {
|
||||
@@ -17,6 +19,8 @@ const App: React.FC = () => {
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/email-templates" element={<EmailTemplates />} />
|
||||
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
401
frontend/src/components/EmailTemplateManagement.tsx
Normal file
401
frontend/src/components/EmailTemplateManagement.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
interface EmailTemplate {
|
||||
template_key: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
html_body: string;
|
||||
text_body: string;
|
||||
variables: string; // This comes as JSON string from backend
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const EmailTemplateManagement: React.FC = () => {
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
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);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template: EmailTemplate) => {
|
||||
setEditingTemplate(template);
|
||||
setShowEditForm(true);
|
||||
};
|
||||
|
||||
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}` }
|
||||
});
|
||||
setShowEditForm(false);
|
||||
setEditingTemplate(null);
|
||||
fetchTemplates(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error('Error updating email template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setShowEditForm(false);
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<button
|
||||
onClick={fetchTemplates}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Refresh Templates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.template_key}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{template.name}</h3>
|
||||
<div>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: template.is_active ? '#d4edda' : '#f8d7da',
|
||||
color: template.is_active ? '#155724' : '#721c24'
|
||||
}}>
|
||||
{template.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
style={{
|
||||
marginLeft: '10px',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong style={{ color: '#666' }}>Key:</strong> <code style={{ backgroundColor: '#f8f9fa', padding: '2px 4px', borderRadius: '3px' }}>{template.template_key}</code>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong style={{ color: '#666' }}>Subject:</strong> <span style={{ color: '#333' }}>{template.subject}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<strong style={{ color: '#666' }}>Variables:</strong>
|
||||
<div style={{ marginTop: '5px', fontFamily: 'monospace', fontSize: '14px', color: '#333' }}>
|
||||
{(() => {
|
||||
try {
|
||||
const vars = JSON.parse(template.variables);
|
||||
return Array.isArray(vars) ? vars.join(', ') : template.variables;
|
||||
} catch {
|
||||
return template.variables;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong style={{ color: '#666' }}>HTML Body Preview:</strong>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '4px',
|
||||
maxHeight: '200px',
|
||||
overflow: 'auto',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.4',
|
||||
color: '#333'
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showEditForm && editingTemplate && (
|
||||
<EmailTemplateEditForm
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface EmailTemplateEditFormProps {
|
||||
template: EmailTemplate;
|
||||
onSave: (template: EmailTemplate) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
|
||||
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 EmailTemplate, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const dataToSave = {
|
||||
template_key: template.template_key,
|
||||
...formData,
|
||||
variables: JSON.stringify(formData.variables)
|
||||
};
|
||||
onSave(dataToSave);
|
||||
}; return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '800px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>Edit Email Template: {template.name}</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Template Key:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={template.template_key}
|
||||
disabled
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f5f5f5'
|
||||
}}
|
||||
/>
|
||||
<small style={{ color: '#666' }}>Template key cannot be changed</small>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Subject:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.subject}
|
||||
onChange={(e) => handleChange('subject', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Variables (comma-separated):
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.variables.join(', ')}
|
||||
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
HTML Body:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.html_body}
|
||||
onChange={(e) => handleChange('html_body', e.target.value)}
|
||||
rows={15}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Text Body:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.text_body}
|
||||
onChange={(e) => handleChange('text_body', e.target.value)}
|
||||
rows={10}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplateManagement;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authService } from '../services/membershipService';
|
||||
import SuperAdminMenu from './SuperAdminMenu';
|
||||
|
||||
interface ProfileMenuProps {
|
||||
userName: string;
|
||||
@@ -11,7 +10,6 @@ interface ProfileMenuProps {
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const [showSuperAdmin, setShowSuperAdmin] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -38,15 +36,6 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSuperAdmin = () => {
|
||||
setShowSuperAdmin(true);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCloseSuperAdmin = () => {
|
||||
setShowSuperAdmin(false);
|
||||
};
|
||||
|
||||
const dropdownStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
@@ -94,17 +83,31 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
||||
{isOpen && (
|
||||
<div style={dropdownStyle}>
|
||||
{userRole === 'super_admin' && (
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
||||
onClick={handleSuperAdmin}
|
||||
>
|
||||
Super Admin Panel
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
||||
onClick={() => {
|
||||
navigate('/membership-tiers');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Membership Tiers
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
|
||||
onClick={() => {
|
||||
navigate('/email-templates');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Email Templates
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
borderRadius: userRole === 'super_admin' ? '0' : '4px 4px 0 0',
|
||||
borderRadius: userRole === 'super_admin' ? '0 0 4px 4px' : '4px 4px 0 0',
|
||||
borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none'
|
||||
}}
|
||||
onClick={handleChangePassword}
|
||||
@@ -122,11 +125,7 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
||||
</div>
|
||||
|
||||
{showChangePassword && (
|
||||
<ChangePasswordModal onClose={handleCloseChangePassword} />
|
||||
)}
|
||||
|
||||
{showSuperAdmin && (
|
||||
<SuperAdminMenu onClose={handleCloseSuperAdmin} />
|
||||
<ChangePasswordModal onClose={() => setShowChangePassword(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||
import EmailTemplateManagement from './EmailTemplateManagement';
|
||||
|
||||
interface SuperAdminMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'system'>('tiers');
|
||||
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
|
||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
@@ -31,9 +32,9 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTier = async (data: MembershipTierCreateData) => {
|
||||
const handleCreateTier = async (data: MembershipTierCreateData | MembershipTierUpdateData) => {
|
||||
try {
|
||||
await membershipService.createTier(data);
|
||||
await membershipService.createTier(data as MembershipTierCreateData);
|
||||
setShowCreateForm(false);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
@@ -97,6 +98,12 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
>
|
||||
User Management
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('email')}
|
||||
className={activeTab === 'email' ? 'tab-active' : 'tab-inactive'}
|
||||
>
|
||||
Email Templates
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('system')}
|
||||
className={activeTab === 'system' ? 'tab-active' : 'tab-inactive'}
|
||||
@@ -128,6 +135,10 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'email' && (
|
||||
<EmailTemplateManagement />
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-placeholder">
|
||||
System settings coming soon...
|
||||
@@ -143,7 +154,7 @@ interface TierManagementProps {
|
||||
loading: boolean;
|
||||
showCreateForm: boolean;
|
||||
editingTier: MembershipTier | null;
|
||||
onCreateTier: (data: MembershipTierCreateData) => void;
|
||||
onCreateTier: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
||||
onUpdateTier: (tierId: number, data: MembershipTierUpdateData) => void;
|
||||
onDeleteTier: (tierId: number) => void;
|
||||
onShowCreateForm: () => void;
|
||||
|
||||
110
frontend/src/pages/EmailTemplates.tsx
Normal file
110
frontend/src/pages/EmailTemplates.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import EmailTemplateManagement from '../components/EmailTemplateManagement';
|
||||
|
||||
const EmailTemplates: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkSuperAdminAccess();
|
||||
}, []);
|
||||
|
||||
const checkSuperAdminAccess = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get('/api/v1/users/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.role !== 'super_admin') {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSuperAdmin(true);
|
||||
} catch (error) {
|
||||
console.error('Error checking user role:', error);
|
||||
navigate('/login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '24px' }}>Email Template Management</h1>
|
||||
<p style={{ margin: '5px 0 0 0', opacity: 0.9 }}>
|
||||
Manage email templates for the membership system
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px' }}>
|
||||
<EmailTemplateManagement />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplates;
|
||||
358
frontend/src/pages/MembershipTiers.tsx
Normal file
358
frontend/src/pages/MembershipTiers.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||
|
||||
const MembershipTiers: React.FC = () => {
|
||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingTier, setEditingTier] = useState<MembershipTier | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadTiers();
|
||||
}, []);
|
||||
|
||||
const loadTiers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const tierData = await membershipService.getAllTiers(true);
|
||||
setTiers(tierData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tiers:', error);
|
||||
alert('Failed to load membership tiers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTier = async (data: MembershipTierCreateData | MembershipTierUpdateData) => {
|
||||
try {
|
||||
await membershipService.createTier(data as MembershipTierCreateData);
|
||||
setShowCreateForm(false);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to create tier');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTier = async (tierId: number, data: MembershipTierUpdateData) => {
|
||||
try {
|
||||
await membershipService.updateTier(tierId, data);
|
||||
setEditingTier(null);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to update tier');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTier = async (tierId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await membershipService.deleteTier(tierId);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to delete tier');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTier = (tier: MembershipTier) => {
|
||||
setEditingTier(tier);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingTier(null);
|
||||
setShowCreateForm(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '200px',
|
||||
fontSize: '16px',
|
||||
color: '#666'
|
||||
}}>
|
||||
Loading membership tiers...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '30px'
|
||||
}}>
|
||||
<h1 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Create New Tier
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '15px' }}>
|
||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{tier.name}</h3>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleEditTier(tier)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTier(tier.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong style={{ color: '#666' }}>Annual Fee:</strong>
|
||||
<span style={{ color: '#28a745', fontWeight: 'bold', marginLeft: '8px' }}>
|
||||
£{tier.annual_fee.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong style={{ color: '#666' }}>Status:</strong>
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: tier.is_active ? '#d4edda' : '#f8d7da',
|
||||
color: tier.is_active ? '#155724' : '#721c24'
|
||||
}}>
|
||||
{tier.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong style={{ color: '#666' }}>Benefits:</strong>
|
||||
<p style={{
|
||||
marginTop: '8px',
|
||||
color: '#555',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{tier.benefits}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(showCreateForm || editingTier) && (
|
||||
<MembershipTierForm
|
||||
tier={editingTier}
|
||||
onSave={editingTier ? (data) => handleUpdateTier(editingTier.id, data) : handleCreateTier}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MembershipTierFormProps {
|
||||
tier: MembershipTier | null;
|
||||
onSave: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MembershipTierForm: React.FC<MembershipTierFormProps> = ({ tier, onSave, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: tier?.name || '',
|
||||
annual_fee: tier?.annual_fee || 0,
|
||||
benefits: tier?.benefits || '',
|
||||
is_active: tier?.is_active ?? true
|
||||
});
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
width: '90%',
|
||||
maxWidth: '500px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>
|
||||
{tier ? 'Edit Membership Tier' : 'Create New Membership Tier'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Annual Fee (£):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.annual_fee}
|
||||
onChange={(e) => handleChange('annual_fee', parseFloat(e.target.value) || 0)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
|
||||
Benefits:
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.benefits}
|
||||
onChange={(e) => handleChange('benefits', e.target.value)}
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => handleChange('is_active', e.target.checked)}
|
||||
style={{ marginRight: '8px' }}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{tier ? 'Update Tier' : 'Create Tier'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembershipTiers;
|
||||
@@ -83,6 +83,13 @@ export interface MembershipCreateData {
|
||||
auto_renew: boolean;
|
||||
}
|
||||
|
||||
export interface MembershipUpdateData {
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
auto_renew?: boolean;
|
||||
}
|
||||
|
||||
export interface PaymentCreateData {
|
||||
amount: number;
|
||||
payment_method: string;
|
||||
|
||||
Reference in New Issue
Block a user