stuff changed:
- 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
This commit is contained in:
@@ -6,11 +6,16 @@ import {
|
||||
ProfileQuestionUpsertData,
|
||||
userService
|
||||
} from '../services/membershipService';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
|
||||
interface AdminProfileQuestionManagerProps {
|
||||
onQuestionsChanged?: () => void;
|
||||
openEditorToken?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
type QuestionSortKey = 'order' | 'label' | 'type' | 'key' | 'status';
|
||||
|
||||
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
|
||||
|
||||
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
|
||||
@@ -34,13 +39,22 @@ const textToOptions = (value: string): ProfileQuestionOption[] => {
|
||||
.filter((option) => option.label.length > 0 && option.value.length > 0);
|
||||
};
|
||||
|
||||
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => {
|
||||
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({
|
||||
onQuestionsChanged,
|
||||
openEditorToken = 0,
|
||||
searchTerm = ''
|
||||
}) => {
|
||||
const { confirm } = useConfirm();
|
||||
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
|
||||
const [listSearch, setListSearch] = useState('');
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<QuestionSortKey>('order');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const pageSize = 10;
|
||||
|
||||
const emptyForm: ProfileQuestionUpsertData = {
|
||||
key: '',
|
||||
@@ -77,9 +91,19 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
loadQuestions();
|
||||
}, []);
|
||||
|
||||
const dependencyCandidates = useMemo(() => {
|
||||
return questions.filter((question) => question.id !== editingQuestionId);
|
||||
}, [questions, editingQuestionId]);
|
||||
useEffect(() => {
|
||||
if (openEditorToken > 0) {
|
||||
setFormData(emptyForm);
|
||||
setOptionsText('');
|
||||
setEditingQuestionId(null);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
}, [openEditorToken]);
|
||||
|
||||
const dependencyCandidates = useMemo(
|
||||
() => questions.filter((question) => question.id !== editingQuestionId),
|
||||
[questions, editingQuestionId]
|
||||
);
|
||||
|
||||
const selectedDependencyQuestion = useMemo(() => {
|
||||
if (!formData.depends_on_question_id) {
|
||||
@@ -89,7 +113,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
}, [questions, formData.depends_on_question_id]);
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const term = listSearch.trim().toLowerCase();
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return questions;
|
||||
}
|
||||
@@ -97,7 +121,60 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
question.label.toLowerCase().includes(term) ||
|
||||
question.key.toLowerCase().includes(term)
|
||||
);
|
||||
}, [questions, listSearch]);
|
||||
}, [questions, searchTerm]);
|
||||
|
||||
const sortedQuestions = 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' });
|
||||
};
|
||||
|
||||
return [...filteredQuestions].sort((left, right) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortKey) {
|
||||
case 'order':
|
||||
result = compareValues(left.display_order ?? 0, right.display_order ?? 0);
|
||||
break;
|
||||
case 'label':
|
||||
result = compareValues(left.label, right.label);
|
||||
break;
|
||||
case 'type':
|
||||
result = compareValues(left.input_type, right.input_type);
|
||||
break;
|
||||
case 'key':
|
||||
result = compareValues(left.key, right.key);
|
||||
break;
|
||||
case 'status':
|
||||
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = compareValues(left.label, right.label);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
}, [filteredQuestions, sortDirection, sortKey]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sortedQuestions.length / pageSize));
|
||||
const paginatedQuestions = useMemo(
|
||||
() => sortedQuestions.slice((currentPage - 1) * pageSize, currentPage * pageSize),
|
||||
[sortedQuestions, currentPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(emptyForm);
|
||||
@@ -105,6 +182,11 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
setEditingQuestionId(null);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
resetForm();
|
||||
setIsEditorOpen(false);
|
||||
};
|
||||
|
||||
const handleEdit = (question: ProfileQuestion) => {
|
||||
setEditingQuestionId(question.id);
|
||||
setFormData({
|
||||
@@ -122,6 +204,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
depends_on_value: question.depends_on_value
|
||||
});
|
||||
setOptionsText(optionsToText(question.options));
|
||||
setIsEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -146,7 +229,7 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
}
|
||||
|
||||
await loadQuestions();
|
||||
resetForm();
|
||||
closeEditor();
|
||||
onQuestionsChanged?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to save question');
|
||||
@@ -156,7 +239,13 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
};
|
||||
|
||||
const handleDeactivate = async (questionId: number) => {
|
||||
if (!window.confirm('Deactivate this question? Existing answers are kept.')) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Deactivate question',
|
||||
message: 'Deactivate this question? Existing answers are kept.',
|
||||
confirmLabel: 'Deactivate',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,238 +258,275 @@ const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> =
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3>
|
||||
<p style={{ marginBottom: '16px', color: '#555' }}>
|
||||
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies.
|
||||
</p>
|
||||
const toggleSort = (nextKey: QuestionSortKey) => {
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Question key (e.g. pilot_license_type)"
|
||||
value={formData.key}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Question label"
|
||||
value={formData.label}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Help text (optional)"
|
||||
value={formData.help_text || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
||||
rows={2}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
||||
<select
|
||||
value={formData.input_type}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
{INPUT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Display order"
|
||||
value={formData.display_order ?? 0}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Placeholder"
|
||||
value={formData.placeholder || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
||||
<select
|
||||
value={formData.depends_on_question_id ?? ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
depends_on_question_id: nextValue ? Number(nextValue) : null,
|
||||
depends_on_value: null
|
||||
}));
|
||||
}}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">No dependency</option>
|
||||
{dependencyCandidates.map((question) => (
|
||||
<option key={question.id} value={question.id}>{question.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{!selectedDependencyQuestion && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Choose a dependency question first"
|
||||
value=""
|
||||
disabled
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'select' && (
|
||||
<select
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
{selectedDependencyQuestion.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
||||
<select
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
||||
<input
|
||||
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
||||
placeholder="Show when parent answer equals..."
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.input_type === 'select' && (
|
||||
<textarea
|
||||
value={optionsText}
|
||||
onChange={(event) => setOptionsText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.is_required)}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.admin_only_edit)}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
Admin-only edits
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.is_active)}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
||||
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
||||
</button>
|
||||
{editingQuestionId && (
|
||||
<button className="btn btn-secondary" onClick={resetForm}>
|
||||
Cancel Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by label or key..."
|
||||
value={listSearch}
|
||||
onChange={(event) => setListSearch(event.target.value)}
|
||||
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
{loading ? (
|
||||
<p>Loading questions...</p>
|
||||
<p className="admin-empty">Loading questions...</p>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredQuestions.map((question) => (
|
||||
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '8px' }}>{question.display_order}</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
{question.label}
|
||||
{question.admin_only_edit && (
|
||||
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
||||
Admin Managed
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px' }}>{question.input_type}</td>
|
||||
<td style={{ padding: '8px' }}>{question.key}</td>
|
||||
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
|
||||
Edit
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-question-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'order' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('order')}>
|
||||
<span>Order</span>{renderSortArrow(sortKey === 'order', sortDirection)}
|
||||
</button>
|
||||
{question.is_active && (
|
||||
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'label' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('label')}>
|
||||
<span>Label</span>{renderSortArrow(sortKey === 'label', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
|
||||
<span>Type</span>{renderSortArrow(sortKey === 'type', 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 === 'status' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('status')}>
|
||||
<span>Status</span>{renderSortArrow(sortKey === 'status', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredQuestions.length === 0 && (
|
||||
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedQuestions.map((question) => (
|
||||
<tr key={question.id}>
|
||||
<td>{question.display_order}</td>
|
||||
<td>
|
||||
{question.label}
|
||||
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
|
||||
</td>
|
||||
<td>{question.input_type}</td>
|
||||
<td>{question.key}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${question.is_active ? 'status-active' : 'status-expired'}`}>
|
||||
{question.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-button-row">
|
||||
<button className="btn btn-secondary" onClick={() => handleEdit(question)}>
|
||||
Edit
|
||||
</button>
|
||||
{question.is_active && (
|
||||
<button className="btn btn-danger" onClick={() => handleDeactivate(question.id)}>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredQuestions.length === 0 && (
|
||||
<p className="admin-empty admin-table-empty">No questions match your search.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-pagination admin-table-footer">
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
<div className="admin-pager-controls">
|
||||
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
|
||||
</button>
|
||||
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditorOpen && (
|
||||
<div className="drawer-overlay" onClick={closeEditor}>
|
||||
<aside className="user-drawer property-drawer admin-question-drawer" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-header-main">
|
||||
<span className="drawer-eyebrow">Profile Question</span>
|
||||
<h3>{editingQuestionId ? 'Edit Question' : 'Create Question'}</h3>
|
||||
</div>
|
||||
<div className="drawer-header-actions">
|
||||
<button className="drawer-close" onClick={closeEditor}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
<div className="drawer-section">
|
||||
<div className="admin-form-grid">
|
||||
<input
|
||||
className="admin-field"
|
||||
type="text"
|
||||
placeholder="Question key"
|
||||
value={formData.key}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="admin-field"
|
||||
type="text"
|
||||
placeholder="Question label"
|
||||
value={formData.label}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
className="admin-field admin-field-textarea"
|
||||
placeholder="Help text"
|
||||
value={formData.help_text || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<div className="admin-field-grid">
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.input_type}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
||||
>
|
||||
{INPUT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
className="admin-field"
|
||||
type="number"
|
||||
placeholder="Display order"
|
||||
value={formData.display_order ?? 0}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
||||
/>
|
||||
|
||||
<input
|
||||
className="admin-field"
|
||||
type="text"
|
||||
placeholder="Placeholder"
|
||||
value={formData.placeholder || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-field-grid">
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.depends_on_question_id ?? ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
depends_on_question_id: nextValue ? Number(nextValue) : null,
|
||||
depends_on_value: null
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">No dependency</option>
|
||||
{dependencyCandidates.map((question) => (
|
||||
<option key={question.id} value={question.id}>{question.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{!selectedDependencyQuestion && (
|
||||
<input
|
||||
className="admin-field admin-field-disabled"
|
||||
type="text"
|
||||
placeholder="Choose a dependency question first"
|
||||
value=""
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'select' && (
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
{selectedDependencyQuestion.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
||||
<select
|
||||
className="admin-field"
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
||||
<input
|
||||
className="admin-field"
|
||||
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
||||
placeholder="Show when parent answer equals..."
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.input_type === 'select' && (
|
||||
<textarea
|
||||
className="admin-field admin-field-textarea"
|
||||
value={optionsText}
|
||||
onChange={(event) => setOptionsText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="admin-switch-group admin-question-switches">
|
||||
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.is_required)} onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))} />Required</label>
|
||||
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.admin_only_edit)} onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))} />Admin-only edits</label>
|
||||
<label className="admin-switch-row"><input type="checkbox" checked={Boolean(formData.is_active)} onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))} />Active</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
||||
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={closeEditor}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
import { formatLondonDateTime, utcMillis } from '../utils/timezone';
|
||||
|
||||
interface BounceRecord {
|
||||
id: number;
|
||||
@@ -22,12 +25,23 @@ interface BounceStats {
|
||||
};
|
||||
}
|
||||
|
||||
const BounceManagement: React.FC = () => {
|
||||
interface BounceManagementProps {
|
||||
cleanupToken?: number;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
type BounceSortKey = 'email' | 'type' | 'reason' | 'date' | 'status';
|
||||
|
||||
const BounceManagement: React.FC<BounceManagementProps> = ({ cleanupToken = 0, searchTerm = '' }) => {
|
||||
const toast = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [bounces, setBounces] = useState<BounceRecord[]>([]);
|
||||
const [stats, setStats] = useState<BounceStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchEmail, setSearchEmail] = useState('');
|
||||
const [filteredBounces, setFilteredBounces] = useState<BounceRecord[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortKey, setSortKey] = useState<BounceSortKey>('date');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
fetchBounces();
|
||||
@@ -35,16 +49,10 @@ const BounceManagement: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchEmail.trim() === '') {
|
||||
setFilteredBounces(bounces);
|
||||
} else {
|
||||
setFilteredBounces(
|
||||
bounces.filter(bounce =>
|
||||
bounce.email.toLowerCase().includes(searchEmail.toLowerCase())
|
||||
)
|
||||
);
|
||||
if (cleanupToken > 0) {
|
||||
void handleCleanupOldBounces();
|
||||
}
|
||||
}, [bounces, searchEmail]);
|
||||
}, [cleanupToken]);
|
||||
|
||||
const fetchBounces = async () => {
|
||||
try {
|
||||
@@ -73,264 +81,197 @@ const BounceManagement: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDeactivateBounce = async (bounceId: number) => {
|
||||
if (!window.confirm('Are you sure you want to deactivate this bounce record?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Resolve bounce record',
|
||||
message: 'Are you sure you want to deactivate this bounce record?',
|
||||
confirmLabel: 'Resolve',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.delete(`/api/v1/email/bounces/${bounceId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
fetchBounces(); // Refresh the list
|
||||
fetchStats(); // Refresh stats
|
||||
fetchBounces();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Error deactivating bounce:', error);
|
||||
alert('Failed to deactivate bounce record');
|
||||
toast.error('Failed to deactivate bounce record.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanupOldBounces = async () => {
|
||||
if (!window.confirm('Are you sure you want to cleanup old soft bounces? This will deactivate soft bounces older than 365 days.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: 'Cleanup old bounces',
|
||||
message: 'Are you sure you want to cleanup old soft bounces?',
|
||||
confirmLabel: 'Cleanup',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await axios.post('/api/v1/email/bounces/cleanup', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
alert(response.data.message);
|
||||
fetchBounces(); // Refresh the list
|
||||
fetchStats(); // Refresh stats
|
||||
toast.success(response.data.message);
|
||||
fetchBounces();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up bounces:', error);
|
||||
alert('Failed to cleanup old bounces');
|
||||
toast.error('Failed to cleanup old bounces.');
|
||||
}
|
||||
};
|
||||
|
||||
const getBounceTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'hard': return '#dc3545';
|
||||
case 'soft': return '#ffc107';
|
||||
case 'complaint': return '#fd7e14';
|
||||
case 'unsubscribe': return '#6c757d';
|
||||
default: return '#6c757d';
|
||||
const filteredBounces = bounces.filter((bounce) =>
|
||||
searchTerm.trim() === '' ? true : bounce.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const sortedBounces = [...filteredBounces].sort((left, right) => {
|
||||
const compareValues = (a: string | number, b: string | number) => {
|
||||
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
||||
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
||||
};
|
||||
|
||||
let result = 0;
|
||||
switch (sortKey) {
|
||||
case 'email':
|
||||
result = compareValues(left.email, right.email);
|
||||
break;
|
||||
case 'type':
|
||||
result = compareValues(left.bounce_type, right.bounce_type);
|
||||
break;
|
||||
case 'reason':
|
||||
result = compareValues(left.bounce_reason || 'ZZZ', right.bounce_reason || 'ZZZ');
|
||||
break;
|
||||
case 'date':
|
||||
result = compareValues(utcMillis(left.bounce_date), utcMillis(right.bounce_date));
|
||||
break;
|
||||
case 'status':
|
||||
result = compareValues(left.is_active ? 0 : 1, right.is_active ? 0 : 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = compareValues(left.email, right.email);
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredBounces.length / pageSize));
|
||||
const paginatedBounces = sortedBounces.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const formatDate = (dateString: string) => formatLondonDateTime(dateString);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) {
|
||||
setCurrentPage(totalPages);
|
||||
}
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const toggleSort = (nextKey: BounceSortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
setSortKey(nextKey);
|
||||
setSortDirection(nextKey === 'date' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
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 (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>Loading bounce data...</div>
|
||||
</div>
|
||||
);
|
||||
return <p className="admin-empty">Loading bounce data...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Statistics Cards */}
|
||||
{stats && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '30px'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Total Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
|
||||
{stats.total_bounces}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Active Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
|
||||
{stats.active_bounces}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Hard Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#dc3545' }}>
|
||||
{stats.bounce_types.hard}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 10px 0', color: '#495057' }}>Soft Bounces</h3>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', color: '#ffc107' }}>
|
||||
{stats.bounce_types.soft}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
<div className="admin-stat-card"><span>Total Bounces</span><strong>{stats.total_bounces}</strong></div>
|
||||
<div className="admin-stat-card attention"><span>Active Bounces</span><strong>{stats.active_bounces}</strong></div>
|
||||
<div className="admin-stat-card"><span>Hard Bounces</span><strong>{stats.bounce_types.hard}</strong></div>
|
||||
<div className="admin-stat-card"><span>Soft Bounces</span><strong>{stats.bounce_types.soft}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
gap: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<label htmlFor="search" style={{ fontWeight: 'bold' }}>Search by Email:</label>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
value={searchEmail}
|
||||
onChange={(e) => setSearchEmail(e.target.value)}
|
||||
placeholder="Enter email address..."
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ced4da',
|
||||
borderRadius: '4px',
|
||||
minWidth: '250px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCleanupOldBounces}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cleanup Old Bounces
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bounce Records Table */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid #dee2e6',
|
||||
backgroundColor: '#f8f9fa'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, color: '#495057' }}>Bounce Records</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse'
|
||||
}}>
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#f8f9fa' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Email</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Type</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Reason</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Date</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left', borderBottom: '1px solid #dee2e6', fontWeight: 'bold' }}>Actions</th>
|
||||
<tr>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'email' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('email')}>
|
||||
<span>Email</span>{renderSortArrow(sortKey === 'email', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'type' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('type')}>
|
||||
<span>Type</span>{renderSortArrow(sortKey === 'type', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'reason' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('reason')}>
|
||||
<span>Reason</span>{renderSortArrow(sortKey === 'reason', sortDirection)}
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button type="button" className={sortKey === 'date' ? 'admin-table-sort active' : 'admin-table-sort'} onClick={() => toggleSort('date')}>
|
||||
<span>Date</span>{renderSortArrow(sortKey === 'date', 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>
|
||||
{filteredBounces.length === 0 ? (
|
||||
{paginatedBounces.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{searchEmail ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
||||
<td colSpan={6} className="admin-table-empty">
|
||||
{searchTerm ? 'No bounces found matching your search.' : 'No bounce records found.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredBounces.map((bounce) => (
|
||||
<tr key={bounce.id} style={{ borderBottom: '1px solid #f1f3f4' }}>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<div style={{ fontWeight: '500' }}>{bounce.email}</div>
|
||||
{bounce.smtp2go_message_id && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#6c757d' }}>
|
||||
ID: {bounce.smtp2go_message_id}
|
||||
</div>
|
||||
)}
|
||||
paginatedBounces.map((bounce) => (
|
||||
<tr key={bounce.id}>
|
||||
<td>
|
||||
<strong>{bounce.email}</strong>
|
||||
{bounce.smtp2go_message_id && <span className="muted-line">ID: {bounce.smtp2go_message_id}</span>}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
backgroundColor: getBounceTypeColor(bounce.bounce_type),
|
||||
color: 'white',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{bounce.bounce_type}
|
||||
<td>
|
||||
<span className={`status-badge ${
|
||||
bounce.bounce_type === 'soft' ? 'status-pending' :
|
||||
bounce.bounce_type === 'hard' ? 'status-expired' :
|
||||
'status-active'
|
||||
}`}>
|
||||
{bounce.bounce_type.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px', maxWidth: '300px' }}>
|
||||
<div style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{bounce.bounce_reason || 'No reason provided'}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
{formatDate(bounce.bounce_date)}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<span style={{
|
||||
color: bounce.is_active ? '#dc3545' : '#28a745',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{bounce.is_active ? 'Active' : 'Resolved'}
|
||||
<td>{bounce.bounce_reason || 'No reason provided'}</td>
|
||||
<td>{formatDate(bounce.bounce_date)}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${bounce.is_active ? 'status-expired' : 'status-active'}`}>
|
||||
{bounce.is_active ? 'ACTIVE' : 'RESOLVED'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td>
|
||||
{bounce.is_active && (
|
||||
<button
|
||||
onClick={() => handleDeactivateBounce(bounce.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-primary" onClick={() => handleDeactivateBounce(bounce.id)}>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
@@ -341,9 +282,20 @@ const BounceManagement: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="admin-pagination admin-table-footer">
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
<div className="admin-pager-controls">
|
||||
<button className="admin-pager-button" disabled={currentPage <= 1} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))} aria-label="Previous page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5 6 8l4.5 4.5" /></svg>
|
||||
</button>
|
||||
<button className="admin-pager-button" disabled={currentPage >= totalPages} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))} aria-label="Next page">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.5 10 8l-4.5 4.5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BounceManagement;
|
||||
export default BounceManagement;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface EmailTemplate {
|
||||
template_key: string;
|
||||
@@ -7,22 +8,55 @@ interface EmailTemplate {
|
||||
subject: string;
|
||||
html_body: string;
|
||||
text_body: string;
|
||||
variables: string; // This comes as JSON string from backend
|
||||
variables: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const EmailTemplateManagement: React.FC = () => {
|
||||
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 [showEditForm, setShowEditForm] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<TemplateSortKey>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
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}` }
|
||||
@@ -30,153 +64,202 @@ const EmailTemplateManagement: React.FC = () => {
|
||||
setTemplates(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching email templates:', error);
|
||||
toast.error('Failed to load email templates.');
|
||||
} 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
|
||||
toast.success('Email template updated.');
|
||||
void fetchTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error updating email template:', error);
|
||||
toast.error('Failed to update email template.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setShowEditForm(false);
|
||||
setEditingTemplate(null);
|
||||
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 <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>;
|
||||
return <p className="admin-empty">Loading email templates...</p>;
|
||||
}
|
||||
|
||||
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 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>
|
||||
|
||||
<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',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: '#333'
|
||||
}}
|
||||
>
|
||||
{template.html_body.substring(0, 300)}
|
||||
{template.html_body.length > 300 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showEditForm && editingTemplate && (
|
||||
{editingTemplate && (
|
||||
<EmailTemplateEditForm
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
onCancel={handleCancelEdit}
|
||||
onCancel={() => setEditingTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -190,6 +273,7 @@ interface EmailTemplateEditFormProps {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -206,198 +290,186 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
|
||||
is_active: template.is_active
|
||||
});
|
||||
|
||||
const handleChange = (field: keyof EmailTemplate, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
const handleChange = (field: keyof typeof formData, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const dataToSave = {
|
||||
onSave({
|
||||
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>
|
||||
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 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 className="drawer-section">
|
||||
<div className="drawer-section-header">
|
||||
<div>
|
||||
<h4>Preview</h4>
|
||||
</div>
|
||||
</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 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>
|
||||
|
||||
<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>
|
||||
{previewMode === 'rendered' && (
|
||||
<div className="email-preview-frame-shell">
|
||||
<iframe
|
||||
title={`${template.name} preview`}
|
||||
className="email-preview-frame"
|
||||
srcDoc={previewDocument}
|
||||
/>
|
||||
</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>
|
||||
{previewMode === 'html' && (
|
||||
<pre className="email-preview-code">{formData.html_body}</pre>
|
||||
)}
|
||||
|
||||
<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
|
||||
/>
|
||||
{previewMode === 'text' && (
|
||||
<pre className="email-preview-code">{formData.text_body}</pre>
|
||||
)}
|
||||
</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>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,30 +5,26 @@ const FeatureFlagStatus: React.FC = () => {
|
||||
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
|
||||
return <div style={{ fontSize: '14px', color: '#8D96A3' }}>Loading feature flags...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
|
||||
return <div style={{ fontSize: '14px', color: '#EE6368' }}>Error loading feature flags</div>;
|
||||
}
|
||||
|
||||
if (!flags) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleReload = async () => {
|
||||
try {
|
||||
await reloadFlags();
|
||||
console.log('Feature flags reloaded');
|
||||
} catch (error) {
|
||||
console.error('Failed to reload feature flags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
|
||||
|
||||
<div className="admin-surface" style={{ marginBottom: '20px' }}>
|
||||
<div className="admin-surface-header">
|
||||
<div>
|
||||
<h4>Feature Flags Status</h4>
|
||||
<p>Environment-driven switches for admin-controlled behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
||||
{Object.entries(flags.flags).map(([name, value]) => (
|
||||
<div
|
||||
@@ -37,23 +33,28 @@ const FeatureFlagStatus: React.FC = () => {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px'
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(16,18,22,0.72)',
|
||||
borderTop: '1px solid rgba(64,71,80,0.55)',
|
||||
borderBottom: '1px solid rgba(34,38,44,0.96)',
|
||||
borderLeft: '1px solid rgba(42,46,52,0.78)',
|
||||
borderRight: '1px solid rgba(42,46,52,0.78)',
|
||||
borderRadius: '3px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: '500' }}>
|
||||
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
|
||||
<span style={{ fontWeight: 500, color: '#E6EBF2' }}>
|
||||
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
backgroundColor: value ? '#4CAF50' : '#f44336',
|
||||
color: 'white'
|
||||
borderRadius: '999px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
background: value ? 'rgba(47,162,82,.13)' : 'rgba(92,31,33,.4)',
|
||||
color: value ? '#2FA252' : '#EE6368',
|
||||
border: `1px solid ${value ? 'rgba(47,162,82,.36)' : 'rgba(238,99,104,.42)'}`
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
@@ -61,20 +62,16 @@ const FeatureFlagStatus: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleReload}
|
||||
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||
>
|
||||
|
||||
<button className="btn btn-secondary" onClick={reloadFlags} style={{ fontSize: '12px', padding: '6px 12px' }}>
|
||||
Reload Flags
|
||||
</button>
|
||||
|
||||
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
|
||||
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
|
||||
|
||||
<p style={{ fontSize: '12px', color: '#8D96A3', marginTop: '12px', marginBottom: 0 }}>
|
||||
Feature flags are loaded from environment variables. Changes require updating the environment and reloading.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureFlagStatus;
|
||||
export default FeatureFlagStatus;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||
import SquarePaymentNew from './SquarePaymentNew';
|
||||
import { londonTodayDateInput } from '../utils/timezone';
|
||||
|
||||
interface MembershipSetupProps {
|
||||
onMembershipCreated: () => void;
|
||||
@@ -85,8 +86,10 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const startDate = new Date().toISOString().split('T')[0];
|
||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const startDate = londonTodayDateInput();
|
||||
const endDateValue = new Date(`${startDate}T00:00:00Z`);
|
||||
endDateValue.setUTCFullYear(endDateValue.getUTCFullYear() + 1);
|
||||
const endDate = endDateValue.toISOString().split('T')[0];
|
||||
|
||||
const membershipData: MembershipCreateData = {
|
||||
tier_id: selectedTier.id,
|
||||
@@ -112,47 +115,38 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
|
||||
if (step === 'select') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>Choose Your Membership</h3>
|
||||
<div className="card member-card member-membership-setup">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership Setup</p>
|
||||
<h3>Choose Your Membership</h3>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div className="membership-tier-grid">
|
||||
{tiers.map(tier => (
|
||||
<div
|
||||
key={tier.id}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#0066cc';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 102, 204, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ddd';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="membership-tier-card"
|
||||
onClick={() => handleTierSelect(tier)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ margin: 0, color: '#0066cc' }}>{tier.name}</h4>
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#0066cc' }}>
|
||||
<div className="membership-tier-header">
|
||||
<h4>{tier.name}</h4>
|
||||
<span className="membership-tier-price">
|
||||
£{tier.annual_fee.toFixed(2)}/year
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '8px 0', color: '#666', fontSize: '14px' }}>{tier.description}</p>
|
||||
<div style={{ backgroundColor: '#f5f5f5', padding: '12px', borderRadius: '4px' }}>
|
||||
<p className="membership-tier-description">{tier.description}</p>
|
||||
<div className="membership-tier-benefits">
|
||||
<strong>Benefits:</strong>
|
||||
<p style={{ marginTop: '4px', fontSize: '14px' }}>{tier.benefits}</p>
|
||||
<p>{tier.benefits}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -167,12 +161,17 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
|
||||
if (step === 'payment') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>Complete Payment</h3>
|
||||
<div className="card member-card member-membership-setup">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership Setup</p>
|
||||
<h3>Complete Payment</h3>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{selectedTier && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="membership-summary-panel">
|
||||
<h4>Selected Membership: {selectedTier.name}</h4>
|
||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||
<p><strong>Benefits:</strong> {selectedTier.benefits}</p>
|
||||
@@ -180,25 +179,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
)}
|
||||
|
||||
{!paymentMethod && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4>
|
||||
<div className="membership-payment-stage">
|
||||
<h4 className="membership-payment-heading">Choose Payment Method</h4>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div className="membership-payment-options">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePaymentMethodSelect('square')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<div>
|
||||
<div className="membership-payment-option-copy">
|
||||
<strong>Credit/Debit Card</strong>
|
||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||
<div>
|
||||
Pay securely with Square
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,17 +203,11 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handlePaymentMethodSelect('cash')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<div>
|
||||
<div className="membership-payment-option-copy">
|
||||
<strong>Cash Payment</strong>
|
||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||
<div>
|
||||
Pay in person or by check
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +216,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -250,7 +237,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
onPaymentSuccess={handleSquarePaymentSuccess}
|
||||
onPaymentError={handleSquarePaymentError}
|
||||
/>
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
@@ -268,26 +255,19 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
|
||||
{paymentMethod === 'cash' && createdMembershipId && (
|
||||
<div>
|
||||
<div style={{
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div className="membership-cash-notice">
|
||||
<strong>Cash Payment Selected</strong>
|
||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||
<p>
|
||||
Your membership will be marked as pending until an administrator confirms payment receipt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="membership-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleCashPayment}
|
||||
disabled={loading}
|
||||
style={{ marginRight: '10px' }}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
||||
</button>
|
||||
@@ -314,13 +294,18 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
const isCashPayment = paymentMethod === 'cash';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>
|
||||
<div className="card member-card member-membership-setup">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership Setup</p>
|
||||
<h3>
|
||||
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
||||
</h3>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTier && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="membership-summary-panel">
|
||||
<h4>Your Membership Details:</h4>
|
||||
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||
@@ -329,7 +314,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
{isCashPayment ? 'Pending' : 'Active'}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
||||
<p className="membership-confirm-copy">
|
||||
{isCashPayment
|
||||
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
||||
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
|
||||
@@ -338,7 +323,7 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div className="membership-setup-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
@@ -354,4 +339,4 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
return null;
|
||||
};
|
||||
|
||||
export default MembershipSetup;
|
||||
export default MembershipSetup;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authService, User } from '../services/membershipService';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatLondonDate } from '../utils/timezone';
|
||||
|
||||
interface ProfileMenuProps {
|
||||
userName: string;
|
||||
@@ -38,115 +40,55 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const dropdownStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
background: 'white',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
minWidth: '280px',
|
||||
maxWidth: '320px',
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
const menuItemStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
};
|
||||
const formatDate = (dateString: string) => formatLondonDate(dateString);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'relative' }} ref={menuRef}>
|
||||
<div className="profile-menu" ref={menuRef}>
|
||||
<button
|
||||
className="profile-menu-trigger"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<span>{userName}</span>
|
||||
<span style={{ fontSize: '12px' }}>▼</span>
|
||||
<span className="profile-menu-chevron">▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={dropdownStyle}>
|
||||
{/* Profile Details Section */}
|
||||
<div className="profile-menu-dropdown">
|
||||
{user && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
borderBottom: '1px solid #eee',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '4px 4px 0 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 'bold', color: '#333' }}>Profile Details</h4>
|
||||
<div className="profile-menu-summary">
|
||||
<div className="profile-menu-summary-head">
|
||||
<h4>Profile Details</h4>
|
||||
{onEditProfile && (
|
||||
<button
|
||||
className="profile-menu-edit"
|
||||
onClick={() => {
|
||||
onEditProfile();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
style={{
|
||||
background: '#0066cc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}>
|
||||
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p>
|
||||
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>}
|
||||
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>}
|
||||
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||
<div className="profile-menu-details">
|
||||
<p><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||
<p><strong>Email:</strong> {user.email}</p>
|
||||
{user.phone && <p><strong>Phone:</strong> {user.phone}</p>}
|
||||
{user.address && <p><strong>Address:</strong> {user.address}</p>}
|
||||
<p><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Items */}
|
||||
<button
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
borderRadius: user ? '0' : '4px 4px 0 0',
|
||||
borderTop: user ? '1px solid #eee' : 'none'
|
||||
}}
|
||||
className={`profile-menu-item ${user ? '' : 'first'}`}
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }}
|
||||
className="profile-menu-item last"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log Out
|
||||
@@ -167,6 +109,7 @@ interface ChangePasswordModalProps {
|
||||
}
|
||||
|
||||
const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -195,7 +138,7 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
||||
new_password: newPassword
|
||||
});
|
||||
|
||||
alert('Password changed successfully!');
|
||||
toast.success('Password changed successfully.');
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.detail || 'Failed to change password');
|
||||
@@ -254,33 +197,19 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
|
||||
<div className="modal-button-row">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
|
||||
@@ -14,22 +14,16 @@ interface ProfileQuestionsFormProps {
|
||||
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
||||
saveLabel?: string;
|
||||
allowAdminManagedEdit?: boolean;
|
||||
surface?: 'member' | 'admin';
|
||||
}
|
||||
|
||||
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
if (question.input_type === 'boolean') {
|
||||
return value === true || value === 'true' ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (value === null || value === undefined || value === '') return 'Not set';
|
||||
if (question.input_type === 'boolean') return value === true || value === 'true' ? 'Yes' : 'No';
|
||||
if (question.input_type === 'select') {
|
||||
const matchingOption = question.options.find((option) => option.value === String(value));
|
||||
return matchingOption?.label || String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
@@ -39,7 +33,8 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
questions,
|
||||
onSave,
|
||||
saveLabel = 'Save Answers',
|
||||
allowAdminManagedEdit = false
|
||||
allowAdminManagedEdit = false,
|
||||
surface = 'admin'
|
||||
}) => {
|
||||
const initialAnswers = useMemo(() => {
|
||||
const values: Record<number, ProfileQuestionAnswerValue> = {};
|
||||
@@ -55,7 +50,6 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,39 +61,31 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
const visibleQuestions = useMemo(() => {
|
||||
const byId = new Map<number, ProfileQuestionForUser>();
|
||||
questions.forEach((question) => byId.set(question.id, question));
|
||||
|
||||
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
|
||||
}, [questions, answers]);
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
return visibleQuestions
|
||||
.filter((question) => {
|
||||
if (!searchTerm) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
question.label.toLowerCase().includes(searchTerm) ||
|
||||
question.key.toLowerCase().includes(searchTerm) ||
|
||||
(question.help_text || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
return visibleQuestions.filter((question) => {
|
||||
if (!searchTerm) return true;
|
||||
return (
|
||||
question.label.toLowerCase().includes(searchTerm) ||
|
||||
question.key.toLowerCase().includes(searchTerm) ||
|
||||
(question.help_text || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}, [visibleQuestions, search]);
|
||||
|
||||
const paginatedQuestions = useMemo(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return filteredQuestions.slice(start, start + pageSize);
|
||||
}, [filteredQuestions, page]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
||||
|
||||
const paginatedQuestions = useMemo(() => {
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return filteredQuestions.slice(start, start + pageSize);
|
||||
}, [filteredQuestions, page, totalPages]);
|
||||
|
||||
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[questionId]: value
|
||||
}));
|
||||
setAnswers((prev) => ({ ...prev, [questionId]: value }));
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
};
|
||||
@@ -117,11 +103,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
|
||||
const changedAnswers: ProfileAnswerInput[] = questions
|
||||
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
|
||||
.filter((question) => {
|
||||
const current = answerToComparable(answers[question.id] ?? null);
|
||||
const initial = answerToComparable(initialAnswers[question.id] ?? null);
|
||||
return current !== initial;
|
||||
})
|
||||
.filter((question) => answerToComparable(answers[question.id] ?? null) !== answerToComparable(initialAnswers[question.id] ?? null))
|
||||
.map((question) => ({
|
||||
question_id: question.id,
|
||||
value: answers[question.id] ?? null
|
||||
@@ -141,27 +123,16 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
|
||||
|
||||
if (disabled && !saving) {
|
||||
return (
|
||||
<div className="profile-question-readonly">
|
||||
{formatAnswerForDisplay(question, value)}
|
||||
</div>
|
||||
);
|
||||
return <div className="profile-question-readonly">{formatAnswerForDisplay(question, value)}</div>;
|
||||
}
|
||||
|
||||
if (question.input_type === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (nextValue === '') {
|
||||
setAnswerValue(question.id, null);
|
||||
} else {
|
||||
setAnswerValue(question.id, nextValue === 'true');
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : event.target.value === 'true')}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
className="profile-question-input"
|
||||
>
|
||||
<option value="">Prefer not to say</option>
|
||||
<option value="true">Yes</option>
|
||||
@@ -176,13 +147,11 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
className="profile-question-input"
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
{question.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
@@ -195,7 +164,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -208,7 +177,7 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
|
||||
disabled={disabled}
|
||||
placeholder={question.placeholder || ''}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -220,60 +189,51 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
placeholder={question.placeholder || ''}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
|
||||
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
|
||||
<div className={`card profile-questions-form ${surface === 'member' ? 'member-surface' : 'admin-surface'}`}>
|
||||
<h3 className="profile-questions-title">{title}</h3>
|
||||
{description && <p className="profile-questions-description">{description}</p>}
|
||||
|
||||
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
|
||||
<div className="profile-questions-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
|
||||
className="profile-question-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="alert alert-success">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{successMessage && <div className="alert alert-success">{successMessage}</div>}
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<p style={{ color: '#666' }}>No questions available.</p>
|
||||
<p className="profile-questions-empty">No questions available.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div className="profile-questions-list">
|
||||
{paginatedQuestions.map((question) => (
|
||||
<div key={question.id} className="profile-question-row">
|
||||
<div
|
||||
key={question.id}
|
||||
className={`profile-question-row ${surface === 'member' ? 'profile-question-row-member' : 'profile-question-row-admin'}`}
|
||||
>
|
||||
<div className="profile-question-meta">
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
|
||||
<label className="profile-question-label">
|
||||
{question.label}
|
||||
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
|
||||
{question.admin_only_edit && (
|
||||
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
||||
Admin Managed
|
||||
</span>
|
||||
)}
|
||||
{question.is_required && <span className="profile-question-required"> *</span>}
|
||||
{question.admin_only_edit && <span className="admin-inline-badge">Admin Managed</span>}
|
||||
</label>
|
||||
{question.help_text && (
|
||||
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
|
||||
<p className="profile-question-help">{question.help_text}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-question-answer">{renderField(question)}</div>
|
||||
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
|
||||
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
|
||||
<p className="profile-question-lock-note">
|
||||
This field can only be changed by an admin.
|
||||
</p>
|
||||
)}
|
||||
@@ -283,22 +243,22 @@ const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
)}
|
||||
|
||||
{filteredQuestions.length > pageSize && (
|
||||
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#525a66' }}>
|
||||
<div className="profile-questions-pagination">
|
||||
<span className="profile-questions-page-copy">
|
||||
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||
<div className="profile-questions-pager-buttons">
|
||||
<button className="btn btn-secondary profile-questions-pager-button" disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||
Previous
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
|
||||
<button className="btn btn-secondary profile-questions-pager-button" disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div className="profile-questions-actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : saveLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||
import EmailTemplateManagement from './EmailTemplateManagement';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useConfirm } from '../contexts/ConfirmContext';
|
||||
import { formatLondonDate } from '../utils/timezone';
|
||||
|
||||
interface SuperAdminMenuProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
const toast = useToast();
|
||||
const { confirm } = useConfirm();
|
||||
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
|
||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -26,7 +31,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
setTiers(tierData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tiers:', error);
|
||||
alert('Failed to load membership tiers');
|
||||
toast.error('Failed to load membership tiers.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -38,7 +43,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
setShowCreateForm(false);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to create tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to create tier.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,12 +53,18 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
setEditingTier(null);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to update tier');
|
||||
toast.error(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.')) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Delete membership tier',
|
||||
message: 'Are you sure you want to delete this membership tier? This action cannot be undone.',
|
||||
confirmLabel: 'Delete',
|
||||
tone: 'danger'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +72,7 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
|
||||
await membershipService.deleteTier(tierId);
|
||||
loadTiers();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to delete tier');
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete tier.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -166,98 +177,74 @@ interface TierManagementProps {
|
||||
export const TierManagement: React.FC<TierManagementProps> = ({
|
||||
tiers,
|
||||
loading,
|
||||
showCreateForm,
|
||||
editingTier,
|
||||
onCreateTier,
|
||||
onUpdateTier,
|
||||
onDeleteTier,
|
||||
onShowCreateForm,
|
||||
onHideCreateForm,
|
||||
onEditTier,
|
||||
onCancelEdit
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-loading">Loading tiers...</div>;
|
||||
return <div className="admin-empty">Loading tiers...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h4>
|
||||
<button
|
||||
onClick={onShowCreateForm}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '14px', padding: '8px 16px' }}
|
||||
>
|
||||
Create New Tier
|
||||
</button>
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h3>Membership Tiers</h3>
|
||||
<p>Manage pricing, availability, and the copy members see when choosing a plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<TierForm
|
||||
onSubmit={onCreateTier}
|
||||
onCancel={onHideCreateForm}
|
||||
title="Create New Membership Tier"
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTier && (
|
||||
<TierForm
|
||||
initialData={editingTier}
|
||||
onSubmit={(data) => onUpdateTier(editingTier.id, data)}
|
||||
onCancel={onCancelEdit}
|
||||
title="Edit Membership Tier"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '20px' }} className="super-admin-table">
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Description</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Annual Fee</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Benefits</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '12px', textAlign: 'left' }}>Actions</th>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Annual Fee</th>
|
||||
<th>Benefits</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tiers.map(tier => (
|
||||
<tr key={tier.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '12px', fontWeight: 'bold' }}>{tier.name}</td>
|
||||
<td style={{ padding: '12px', maxWidth: '200px' }}>
|
||||
{tier.description || 'No description'}
|
||||
<tr key={tier.id}>
|
||||
<td>
|
||||
<strong>{tier.name}</strong>
|
||||
<span className="muted-line">Created {formatLondonDate(tier.created_at)}</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>£{tier.annual_fee.toFixed(2)}</td>
|
||||
<td style={{ padding: '12px', maxWidth: '250px' }}>
|
||||
{tier.benefits || 'No benefits listed'}
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td>{tier.description || 'No description'}</td>
|
||||
<td>£{tier.annual_fee.toFixed(2)}</td>
|
||||
<td className="admin-tier-benefits-cell">{tier.benefits || 'No benefits listed'}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${tier.is_active ? 'status-active' : 'status-expired'}`}>
|
||||
{tier.is_active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px' }}>
|
||||
<td>
|
||||
<div className="table-button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditTier(tier)}
|
||||
className="action-btn"
|
||||
style={{ marginRight: '8px', color: 'white', backgroundColor: '#007bff', border: '1px solid #007bff' }}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteTier(tier.id)}
|
||||
className="action-btn action-btn-danger"
|
||||
style={{ color: 'white', backgroundColor: '#dc3545', border: '1px solid #dc3545' }}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{tiers.length === 0 && <p className="admin-empty admin-table-empty">No membership tiers found.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -268,9 +255,10 @@ interface TierFormProps {
|
||||
onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
|
||||
onCancel: () => void;
|
||||
title: string;
|
||||
variant?: 'inline' | 'rail' | 'drawer';
|
||||
}
|
||||
|
||||
const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title }) => {
|
||||
export const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, title, variant = 'inline' }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
@@ -288,18 +276,21 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const panelClassName =
|
||||
variant === 'rail'
|
||||
? 'admin-rail-form-panel'
|
||||
: variant === 'drawer'
|
||||
? 'admin-drawer-form-panel'
|
||||
: 'admin-inline-form-panel';
|
||||
|
||||
const gridClassName = variant === 'inline' ? 'admin-inline-form-grid' : 'admin-rail-form-grid';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '20px',
|
||||
border: '1px solid #dee2e6'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>{title}</h4>
|
||||
<div className={panelClassName}>
|
||||
<h4>{title}</h4>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div className={gridClassName}>
|
||||
<div className="modal-form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
@@ -323,7 +314,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
||||
<div className="modal-form-group">
|
||||
<label>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -333,28 +324,19 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-form-group" style={{ marginBottom: '16px' }}>
|
||||
<div className="modal-form-group">
|
||||
<label>Benefits</label>
|
||||
<textarea
|
||||
value={formData.benefits}
|
||||
onChange={(e) => handleChange('benefits', e.target.value)}
|
||||
placeholder="List the benefits of this membership tier"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
backgroundColor: '#fff',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
className="admin-inline-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div className="admin-inline-toggle-row">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
@@ -364,7 +346,7 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
|
||||
<div className="modal-buttons">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
|
||||
Reference in New Issue
Block a user