tractatus/public/js/admin/newsletter-management.js
TheFlow 996395f811 fix: Clear newsletter form fields after successful test send
Improves UX by resetting form after test email is successfully sent,
allowing admin to start fresh for the next newsletter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 16:30:22 +13:00

517 lines
15 KiB
JavaScript

/**
* Newsletter Management - Admin Interface
*/
let currentPage = 1;
const perPage = 50;
let currentFilters = {
status: 'active',
verified: 'all'
};
/**
* Initialize page
*/
async function init() {
// Event listeners (navbar handles admin name and logout now)
document.getElementById('refresh-btn').addEventListener('click', () => loadAll());
document.getElementById('export-btn').addEventListener('click', exportSubscribers);
document.getElementById('filter-status').addEventListener('change', handleFilterChange);
document.getElementById('filter-verified').addEventListener('change', handleFilterChange);
document.getElementById('prev-page').addEventListener('click', () => changePage(-1));
document.getElementById('next-page').addEventListener('click', () => changePage(1));
// Newsletter sending form listeners
document.getElementById('send-newsletter-form').addEventListener('submit', handleSendNewsletter);
document.getElementById('preview-newsletter-btn').addEventListener('click', handlePreviewNewsletter);
document.getElementById('test-newsletter-btn').addEventListener('click', handleTestNewsletter);
// Load data
await loadAll();
}
/**
* Load all data
*/
async function loadAll() {
await Promise.all([
loadStats(),
loadSubscribers()
]);
}
/**
* Load statistics
*/
async function loadStats() {
try {
const token = localStorage.getItem('admin_token');
const response = await fetch('/api/newsletter/admin/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load stats');
const data = await response.json();
const stats = data.stats;
document.getElementById('stat-total').textContent = stats.total || 0;
document.getElementById('stat-active').textContent = stats.active || 0;
document.getElementById('stat-verified').textContent = stats.verified || 0;
document.getElementById('stat-recent').textContent = stats.recent_30_days || 0;
} catch (error) {
console.error('Error loading stats:', error);
}
}
/**
* Load subscribers list
*/
async function loadSubscribers() {
try {
const token = localStorage.getItem('admin_token');
const skip = (currentPage - 1) * perPage;
const params = new URLSearchParams({
limit: perPage,
skip,
active: currentFilters.status === 'all' ? null : currentFilters.status === 'active',
verified: currentFilters.verified === 'all' ? null : currentFilters.verified === 'verified'
});
// Remove null values
for (const [key, value] of [...params.entries()]) {
if (value === 'null' || value === null) {
params.delete(key);
}
}
const response = await fetch(`/api/newsletter/admin/subscriptions?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load subscribers');
const data = await response.json();
renderSubscribers(data.subscriptions);
updatePagination(data.pagination);
} catch (error) {
console.error('Error loading subscribers:', error);
document.getElementById('subscribers-table').innerHTML = `
<tr>
<td colspan="6" class="px-6 py-8 text-center text-red-600">
Error loading subscribers. Please refresh the page.
</td>
</tr>
`;
}
}
/**
* Render subscribers table
*/
function renderSubscribers(subscriptions) {
const tbody = document.getElementById('subscribers-table');
if (!subscriptions || subscriptions.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
No subscribers found
</td>
</tr>
`;
return;
}
tbody.innerHTML = subscriptions.map(sub => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${escapeHtml(sub.email)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${escapeHtml(sub.name) || '-'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${escapeHtml(sub.source) || 'unknown'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
${sub.active
? `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
${sub.verified ? '<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>' : ''}
${sub.verified ? 'Active ✓' : 'Active'}
</span>`
: '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>'
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
${formatDate(sub.subscribed_at)}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button class="view-details-btn text-blue-600 hover:text-blue-900 mr-3" data-id="${String(sub._id)}">View</button>
<button class="delete-subscriber-btn text-red-600 hover:text-red-900" data-id="${String(sub._id)}" data-email="${escapeHtml(sub.email)}">Delete</button>
</td>
</tr>
`).join('');
// Add event listeners to buttons
tbody.querySelectorAll('.view-details-btn').forEach(btn => {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
viewDetails(id);
});
});
tbody.querySelectorAll('.delete-subscriber-btn').forEach(btn => {
btn.addEventListener('click', function() {
const id = this.getAttribute('data-id');
const email = this.getAttribute('data-email');
deleteSubscriber(id, email);
});
});
}
/**
* Update pagination UI
*/
function updatePagination(pagination) {
document.getElementById('showing-from').textContent = pagination.skip + 1;
document.getElementById('showing-to').textContent = Math.min(pagination.skip + pagination.limit, pagination.total);
document.getElementById('total-count').textContent = pagination.total;
document.getElementById('prev-page').disabled = currentPage === 1;
document.getElementById('next-page').disabled = !pagination.has_more;
}
/**
* Handle filter change
*/
function handleFilterChange() {
currentFilters.status = document.getElementById('filter-status').value;
currentFilters.verified = document.getElementById('filter-verified').value;
currentPage = 1;
loadSubscribers();
}
/**
* Change page
*/
function changePage(direction) {
currentPage += direction;
loadSubscribers();
}
/**
* View subscriber details
*/
async function viewDetails(id) {
alert(`Subscriber details for ID: ${id}\n(Full implementation would show a modal with complete subscriber information)`);
}
/**
* Delete subscriber
*/
async function deleteSubscriber(id, email) {
if (!confirm(`Are you sure you want to delete subscription for ${email}?\n\nThis action cannot be undone.`)) {
return;
}
try {
const token = localStorage.getItem('admin_token');
const response = await fetch(`/api/newsletter/admin/subscriptions/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to delete subscriber');
alert('Subscriber deleted successfully');
await loadAll();
} catch (error) {
console.error('Error deleting subscriber:', error);
alert('Failed to delete subscriber. Please try again.');
}
}
/**
* Export subscribers as CSV
*/
async function exportSubscribers() {
try {
const token = localStorage.getItem('admin_token');
const active = currentFilters.status === 'all' ? 'all' : 'true';
const response = await fetch(`/api/newsletter/admin/export?active=${active}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to export subscribers');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `newsletter-subscribers-${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Error exporting subscribers:', error);
alert('Failed to export subscribers. Please try again.');
}
}
// Logout handled by navbar component
/**
* Format date
*/
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Escape HTML
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Get CSRF token from cookie
*/
function getCsrfToken() {
const cookies = document.cookie.split('; ');
const csrfCookie = cookies.find(row => row.startsWith('csrf-token='));
return csrfCookie ? csrfCookie.split('=')[1] : null;
}
/**
* Validate newsletter form
*/
function validateNewsletterForm() {
const tier = document.getElementById('newsletter-tier').value;
const subject = document.getElementById('newsletter-subject').value;
if (!tier) {
showStatus('error', 'Please select a newsletter tier');
return null;
}
if (!subject) {
showStatus('error', 'Please enter a subject line');
return null;
}
// Collect content from individual form fields
const variables = {
highlight_1_title: document.getElementById('highlight_1_title').value,
highlight_1_summary: document.getElementById('highlight_1_summary').value,
highlight_1_link: document.getElementById('highlight_1_link').value,
finding_1: document.getElementById('finding_1').value,
question_1: document.getElementById('question_1').value,
feedback_link: document.getElementById('feedback_link').value,
blog_link: document.getElementById('blog_link').value
};
// Basic validation - at least one content field should be filled
const hasContent = Object.values(variables).some(val => val && val.trim());
if (!hasContent) {
showStatus('error', 'Please fill in at least one content field');
return null;
}
return {
tier,
subject,
previewText: document.getElementById('newsletter-preview').value,
variables
};
}
/**
* Show status message
*/
function showStatus(type, message) {
const statusDiv = document.getElementById('send-status');
statusDiv.className = `px-4 py-3 rounded ${
type === 'success' ? 'bg-green-50 border border-green-200 text-green-800' :
type === 'error' ? 'bg-red-50 border border-red-200 text-red-800' :
'bg-blue-50 border border-blue-200 text-blue-800'
}`;
statusDiv.textContent = message;
statusDiv.classList.remove('hidden');
}
/**
* Handle newsletter preview
*/
async function handlePreviewNewsletter(e) {
e.preventDefault();
const formData = validateNewsletterForm();
if (!formData) return;
try {
showStatus('info', 'Generating preview...');
const token = localStorage.getItem('admin_token');
const csrfToken = getCsrfToken();
const response = await fetch('/api/newsletter/admin/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
tier: formData.tier,
variables: formData.variables
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Preview failed');
}
const html = await response.text();
// Open preview in new window
const previewWindow = window.open('', '_blank', 'width=800,height=600');
previewWindow.document.write(html);
previewWindow.document.close();
showStatus('success', 'Preview opened in new window');
} catch (error) {
console.error('Preview error:', error);
showStatus('error', `Preview failed: ${error.message}`);
}
}
/**
* Handle test newsletter send
*/
async function handleTestNewsletter(e) {
e.preventDefault();
const formData = validateNewsletterForm();
if (!formData) return;
const testEmail = prompt('Enter your email address for test send:');
if (!testEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(testEmail)) {
showStatus('error', 'Valid email address is required for test send');
return;
}
try {
showStatus('info', 'Sending test email...');
const token = localStorage.getItem('admin_token');
const csrfToken = getCsrfToken();
const response = await fetch('/api/newsletter/admin/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
...formData,
testMode: true,
testEmail
})
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('success', `Test email sent to ${testEmail}`);
// Clear form on successful test send
document.getElementById('send-newsletter-form').reset();
} else {
throw new Error(result.error || 'Test send failed');
}
} catch (error) {
console.error('Test send error:', error);
showStatus('error', `Test send failed: ${error.message}`);
}
}
/**
* Handle newsletter send to all subscribers
*/
async function handleSendNewsletter(e) {
e.preventDefault();
const formData = validateNewsletterForm();
if (!formData) return;
const confirmation = confirm(
`Are you sure you want to send this newsletter to all subscribers of the "${formData.tier}" tier?\n\n` +
`Subject: ${formData.subject}\n\n` +
`This action cannot be undone.`
);
if (!confirmation) {
return;
}
try {
showStatus('info', 'Sending newsletter to subscribers...');
const token = localStorage.getItem('admin_token');
const csrfToken = getCsrfToken();
const response = await fetch('/api/newsletter/admin/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({
...formData,
testMode: false
})
});
const result = await response.json();
if (response.ok && result.success) {
showStatus('success', `${result.message}\n\nSent: ${result.sent}, Failed: ${result.failed}`);
// Clear form on success
if (result.failed === 0) {
document.getElementById('send-newsletter-form').reset();
}
} else {
throw new Error(result.error || 'Send failed');
}
} catch (error) {
console.error('Send error:', error);
showStatus('error', `Send failed: ${error.message}`);
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', init);