1191 lines
40 KiB
JavaScript
1191 lines
40 KiB
JavaScript
/**
|
|
* UI Helper Functions and Components
|
|
* Handles DOM manipulation, notifications, modals, and UI state
|
|
*/
|
|
|
|
class UIManager {
|
|
constructor() {
|
|
this.currentTab = 'lists';
|
|
this.currentEditingItem = null;
|
|
this.isLoading = false;
|
|
this.confirmCallback = null;
|
|
|
|
this.initializeEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Initialize all event listeners
|
|
*/
|
|
initializeEventListeners() {
|
|
// Tab navigation
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
this.switchTab(e.target.dataset.tab);
|
|
});
|
|
});
|
|
|
|
// Modal close buttons
|
|
document.querySelectorAll('.modal-close, .modal .btn-secondary').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
this.closeModal(e.target.closest('.modal'));
|
|
});
|
|
});
|
|
|
|
// Click outside modal to close
|
|
document.querySelectorAll('.modal').forEach(modal => {
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
this.closeModal(modal);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Notification close
|
|
document.getElementById('notificationClose').addEventListener('click', () => {
|
|
this.hideNotification();
|
|
});
|
|
|
|
// Add buttons
|
|
document.getElementById('addListBtn').addEventListener('click', () => {
|
|
this.showListModal();
|
|
});
|
|
|
|
// Initialize bulk import listeners
|
|
this.initializeBulkImportListeners();
|
|
|
|
document.getElementById('addMemberBtn').addEventListener('click', () => {
|
|
this.showMemberModal();
|
|
});
|
|
|
|
|
|
|
|
// Member subscriptions modal
|
|
document.getElementById('memberSubscriptionsModalClose').addEventListener('click', () => {
|
|
this.closeModal(document.getElementById('memberSubscriptionsModal'));
|
|
});
|
|
|
|
document.getElementById('memberSubscriptionsCancelBtn').addEventListener('click', () => {
|
|
this.closeModal(document.getElementById('memberSubscriptionsModal'));
|
|
});
|
|
|
|
document.getElementById('memberSubscriptionsSaveBtn').addEventListener('click', () => {
|
|
this.handleMemberSubscriptionsSave();
|
|
});
|
|
|
|
// Form submissions
|
|
document.getElementById('listForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.handleListFormSubmit();
|
|
});
|
|
|
|
document.getElementById('memberForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.handleMemberFormSubmit();
|
|
});
|
|
|
|
document.getElementById('subscriptionForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.handleSubscriptionFormSubmit();
|
|
});
|
|
|
|
// Confirmation modal
|
|
document.getElementById('confirmOkBtn').addEventListener('click', () => {
|
|
if (this.confirmCallback) {
|
|
this.confirmCallback();
|
|
this.confirmCallback = null;
|
|
}
|
|
this.closeModal(document.getElementById('confirmModal'));
|
|
});
|
|
|
|
document.getElementById('confirmCancelBtn').addEventListener('click', () => {
|
|
this.confirmCallback = null;
|
|
this.closeModal(document.getElementById('confirmModal'));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show/hide loading overlay
|
|
*/
|
|
setLoading(loading) {
|
|
this.isLoading = loading;
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
if (loading) {
|
|
overlay.style.display = 'flex';
|
|
} else {
|
|
overlay.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show notification
|
|
*/
|
|
showNotification(message, type = 'info') {
|
|
const notification = document.getElementById('notification');
|
|
const messageEl = document.getElementById('notificationMessage');
|
|
|
|
notification.className = `notification ${type}`;
|
|
messageEl.textContent = message;
|
|
notification.style.display = 'flex';
|
|
|
|
// Auto-hide after 5 seconds
|
|
setTimeout(() => {
|
|
this.hideNotification();
|
|
}, 5000);
|
|
}
|
|
|
|
/**
|
|
* Hide notification
|
|
*/
|
|
hideNotification() {
|
|
document.getElementById('notification').style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Show confirmation dialog
|
|
*/
|
|
showConfirmation(message, callback) {
|
|
document.getElementById('confirmMessage').textContent = message;
|
|
this.confirmCallback = callback;
|
|
this.showModal(document.getElementById('confirmModal'));
|
|
}
|
|
|
|
/**
|
|
* Switch between tabs
|
|
*/
|
|
switchTab(tabName) {
|
|
this.currentTab = tabName;
|
|
|
|
// Update tab buttons
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
});
|
|
|
|
// Update tab content
|
|
document.querySelectorAll('.tab-content').forEach(content => {
|
|
content.classList.toggle('active', content.id === `${tabName}-tab`);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show modal
|
|
*/
|
|
showModal(modal) {
|
|
modal.classList.add('active');
|
|
// Focus first input
|
|
const firstInput = modal.querySelector('input, select, textarea');
|
|
if (firstInput) {
|
|
setTimeout(() => firstInput.focus(), 100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close modal
|
|
*/
|
|
closeModal(modal) {
|
|
modal.classList.remove('active');
|
|
this.currentEditingItem = null;
|
|
|
|
// Reset forms
|
|
const form = modal.querySelector('form');
|
|
if (form) {
|
|
form.reset();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show list modal (add/edit)
|
|
*/
|
|
showListModal(listData = null) {
|
|
const modal = document.getElementById('listModal');
|
|
const title = document.getElementById('listModalTitle');
|
|
const form = document.getElementById('listForm');
|
|
|
|
if (listData) {
|
|
// Edit mode
|
|
title.textContent = 'Edit Mailing List';
|
|
document.getElementById('listName').value = listData.list_name;
|
|
document.getElementById('listEmail').value = listData.list_email;
|
|
document.getElementById('listDescription').value = listData.description || '';
|
|
document.getElementById('listActive').checked = listData.active;
|
|
this.currentEditingItem = listData;
|
|
} else {
|
|
// Add mode
|
|
title.textContent = 'Add Mailing List';
|
|
form.reset();
|
|
document.getElementById('listActive').checked = true;
|
|
this.currentEditingItem = null;
|
|
}
|
|
|
|
this.showModal(modal);
|
|
}
|
|
|
|
/**
|
|
* Show member modal (add/edit)
|
|
*/
|
|
showMemberModal(memberData = null) {
|
|
const modal = document.getElementById('memberModal');
|
|
const title = document.getElementById('memberModalTitle');
|
|
const form = document.getElementById('memberForm');
|
|
|
|
if (memberData) {
|
|
// Edit mode
|
|
title.textContent = 'Edit Member';
|
|
document.getElementById('memberName').value = memberData.name;
|
|
document.getElementById('memberEmail').value = memberData.email;
|
|
document.getElementById('memberActive').checked = memberData.active;
|
|
this.currentEditingItem = memberData;
|
|
} else {
|
|
// Add mode
|
|
title.textContent = 'Add Member';
|
|
form.reset();
|
|
document.getElementById('memberActive').checked = true;
|
|
this.currentEditingItem = null;
|
|
}
|
|
|
|
this.showModal(modal);
|
|
}
|
|
|
|
/**
|
|
* Show subscription modal
|
|
*/
|
|
async showSubscriptionModal() {
|
|
const modal = document.getElementById('subscriptionModal');
|
|
|
|
try {
|
|
// Populate dropdowns
|
|
await this.populateSubscriptionDropdowns();
|
|
this.showModal(modal);
|
|
} catch (error) {
|
|
this.showNotification('Failed to load subscription data', 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Populate subscription modal dropdowns
|
|
*/
|
|
async populateSubscriptionDropdowns() {
|
|
const listSelect = document.getElementById('subscriptionList');
|
|
const memberSelect = document.getElementById('subscriptionMember');
|
|
|
|
// Clear existing options
|
|
listSelect.innerHTML = '<option value="">Select a list...</option>';
|
|
memberSelect.innerHTML = '<option value="">Select a member...</option>';
|
|
|
|
try {
|
|
const [lists, members] = await Promise.all([
|
|
apiClient.getLists(),
|
|
apiClient.getMembers()
|
|
]);
|
|
|
|
// Populate lists
|
|
lists.forEach(list => {
|
|
if (list.active) {
|
|
const option = document.createElement('option');
|
|
option.value = list.list_email;
|
|
option.textContent = `${list.list_name} (${list.list_email})`;
|
|
listSelect.appendChild(option);
|
|
}
|
|
});
|
|
|
|
// Populate members
|
|
members.forEach(member => {
|
|
if (member.active) {
|
|
const option = document.createElement('option');
|
|
option.value = member.email;
|
|
option.textContent = `${member.name} (${member.email})`;
|
|
memberSelect.appendChild(option);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle list form submission
|
|
*/
|
|
async handleListFormSubmit() {
|
|
const form = document.getElementById('listForm');
|
|
const formData = new FormData(form);
|
|
|
|
const listData = {
|
|
list_name: formData.get('listName'),
|
|
list_email: formData.get('listEmail'),
|
|
description: formData.get('listDescription') || null,
|
|
active: formData.get('listActive') === 'on'
|
|
};
|
|
|
|
try {
|
|
this.setLoading(true);
|
|
|
|
if (this.currentEditingItem) {
|
|
// Update existing list
|
|
await apiClient.updateList(this.currentEditingItem.list_id, listData);
|
|
this.showNotification('Mailing list updated successfully', 'success');
|
|
} else {
|
|
// Create new list
|
|
await apiClient.createList(listData);
|
|
this.showNotification('Mailing list created successfully', 'success');
|
|
}
|
|
|
|
this.closeModal(document.getElementById('listModal'));
|
|
await window.app.loadData();
|
|
} catch (error) {
|
|
this.handleError(error, 'Failed to save mailing list');
|
|
} finally {
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle member form submission
|
|
*/
|
|
async handleMemberFormSubmit() {
|
|
const form = document.getElementById('memberForm');
|
|
const formData = new FormData(form);
|
|
|
|
const memberData = {
|
|
name: formData.get('memberName'),
|
|
email: formData.get('memberEmail'),
|
|
active: formData.get('memberActive') === 'on'
|
|
};
|
|
|
|
try {
|
|
this.setLoading(true);
|
|
|
|
if (this.currentEditingItem) {
|
|
// Update existing member
|
|
await apiClient.updateMember(this.currentEditingItem.member_id, memberData);
|
|
this.showNotification('Member updated successfully', 'success');
|
|
} else {
|
|
// Create new member
|
|
await apiClient.createMember(memberData);
|
|
this.showNotification('Member created successfully', 'success');
|
|
}
|
|
|
|
this.closeModal(document.getElementById('memberModal'));
|
|
await window.app.loadData();
|
|
} catch (error) {
|
|
this.handleError(error, 'Failed to save member');
|
|
} finally {
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle subscription form submission
|
|
*/
|
|
async handleSubscriptionFormSubmit() {
|
|
const form = document.getElementById('subscriptionForm');
|
|
const formData = new FormData(form);
|
|
|
|
const subscriptionData = {
|
|
list_email: formData.get('subscriptionList'),
|
|
member_email: formData.get('subscriptionMember'),
|
|
active: true
|
|
};
|
|
|
|
try {
|
|
this.setLoading(true);
|
|
|
|
await apiClient.createSubscription(subscriptionData);
|
|
this.showNotification('Subscription created successfully', 'success');
|
|
|
|
this.closeModal(document.getElementById('subscriptionModal'));
|
|
await window.app.loadData();
|
|
} catch (error) {
|
|
this.handleError(error, 'Failed to create subscription');
|
|
} finally {
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show member subscriptions modal
|
|
*/
|
|
async showMemberSubscriptionsModal(member) {
|
|
const modal = document.getElementById('memberSubscriptionsModal');
|
|
|
|
// Update member info
|
|
document.getElementById('memberSubscriptionsName').textContent = member.name;
|
|
document.getElementById('memberSubscriptionsEmail').textContent = member.email;
|
|
document.getElementById('memberSubscriptionsTitle').textContent = `Manage Subscriptions - ${member.name}`;
|
|
|
|
try {
|
|
// Load all lists and member's current subscriptions
|
|
const [lists, memberSubscriptions] = await Promise.all([
|
|
apiClient.getLists(),
|
|
this.getMemberSubscriptions(member.member_id)
|
|
]);
|
|
|
|
this.currentMemberForSubscriptions = member;
|
|
this.renderSubscriptionCheckboxes(lists, memberSubscriptions);
|
|
this.showModal(modal);
|
|
} catch (error) {
|
|
this.handleError(error, 'Failed to load subscription data');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get member's current subscriptions
|
|
*/
|
|
async getMemberSubscriptions(memberId) {
|
|
const subscriptions = [];
|
|
|
|
// Check each list to see if the member is subscribed
|
|
for (const [listId, members] of window.app.subscriptions) {
|
|
if (members.some(m => m.member_id === memberId)) {
|
|
const list = window.app.lists.find(l => l.list_id === listId);
|
|
if (list) {
|
|
subscriptions.push(list);
|
|
}
|
|
}
|
|
}
|
|
|
|
return subscriptions;
|
|
}
|
|
|
|
/**
|
|
* Render subscription checkboxes
|
|
*/
|
|
renderSubscriptionCheckboxes(lists, memberSubscriptions) {
|
|
const container = document.getElementById('subscriptionCheckboxList');
|
|
container.innerHTML = '';
|
|
|
|
this.subscriptionChanges = new Map(); // Track changes
|
|
|
|
lists.forEach(list => {
|
|
const isSubscribed = memberSubscriptions.some(sub => sub.list_id === list.list_id);
|
|
|
|
const item = document.createElement('div');
|
|
item.className = `subscription-item ${isSubscribed ? 'subscribed' : ''}`;
|
|
item.dataset.listId = list.list_id;
|
|
item.dataset.subscribed = isSubscribed.toString();
|
|
|
|
item.innerHTML = `
|
|
<div class="list-info">
|
|
<div class="list-name">${this.escapeHtml(list.list_name)}</div>
|
|
<div class="list-email">${this.escapeHtml(list.list_email)}</div>
|
|
</div>
|
|
<div class="subscription-toggle">
|
|
<div class="toggle-switch ${isSubscribed ? 'active' : ''}" data-list-id="${list.list_id}"></div>
|
|
<span class="subscription-status ${isSubscribed ? 'subscribed' : 'not-subscribed'}">
|
|
${isSubscribed ? 'Subscribed' : 'Not subscribed'}
|
|
</span>
|
|
</div>
|
|
`;
|
|
|
|
// Add click handlers
|
|
const toggleSwitch = item.querySelector('.toggle-switch');
|
|
const statusSpan = item.querySelector('.subscription-status');
|
|
|
|
const toggleSubscription = () => {
|
|
const currentlySubscribed = item.dataset.subscribed === 'true';
|
|
const newSubscribed = !currentlySubscribed;
|
|
|
|
// Update UI
|
|
item.dataset.subscribed = newSubscribed.toString();
|
|
item.className = `subscription-item ${newSubscribed ? 'subscribed' : ''}`;
|
|
toggleSwitch.className = `toggle-switch ${newSubscribed ? 'active' : ''}`;
|
|
statusSpan.className = `subscription-status ${newSubscribed ? 'subscribed' : 'not-subscribed'}`;
|
|
statusSpan.textContent = newSubscribed ? 'Subscribed' : 'Not subscribed';
|
|
|
|
// Track the change
|
|
const originallySubscribed = memberSubscriptions.some(sub => sub.list_id === list.list_id);
|
|
if (newSubscribed !== originallySubscribed) {
|
|
this.subscriptionChanges.set(list.list_id, {
|
|
list: list,
|
|
action: newSubscribed ? 'subscribe' : 'unsubscribe'
|
|
});
|
|
} else {
|
|
this.subscriptionChanges.delete(list.list_id);
|
|
}
|
|
|
|
// Update save button state
|
|
this.updateSubscriptionsSaveButton();
|
|
};
|
|
|
|
item.addEventListener('click', toggleSubscription);
|
|
toggleSwitch.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleSubscription();
|
|
});
|
|
|
|
container.appendChild(item);
|
|
});
|
|
|
|
this.updateSubscriptionsSaveButton();
|
|
}
|
|
|
|
/**
|
|
* Update save button state based on changes
|
|
*/
|
|
updateSubscriptionsSaveButton() {
|
|
const saveBtn = document.getElementById('memberSubscriptionsSaveBtn');
|
|
const hasChanges = this.subscriptionChanges && this.subscriptionChanges.size > 0;
|
|
|
|
saveBtn.disabled = !hasChanges;
|
|
saveBtn.innerHTML = hasChanges
|
|
? `<i class="fas fa-save"></i> Save Changes (${this.subscriptionChanges.size})`
|
|
: `<i class="fas fa-save"></i> No Changes`;
|
|
}
|
|
|
|
/**
|
|
* Handle saving subscription changes
|
|
*/
|
|
async handleMemberSubscriptionsSave() {
|
|
if (!this.subscriptionChanges || this.subscriptionChanges.size === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.setLoading(true);
|
|
|
|
const promises = [];
|
|
|
|
for (const [listId, change] of this.subscriptionChanges) {
|
|
if (change.action === 'subscribe') {
|
|
promises.push(
|
|
apiClient.createSubscription({
|
|
list_email: change.list.list_email,
|
|
member_email: this.currentMemberForSubscriptions.email,
|
|
active: true
|
|
})
|
|
);
|
|
} else {
|
|
promises.push(
|
|
apiClient.deleteSubscription(
|
|
change.list.list_email,
|
|
this.currentMemberForSubscriptions.email
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
|
|
this.showNotification(`Successfully updated ${this.subscriptionChanges.size} subscription(s)`, 'success');
|
|
this.closeModal(document.getElementById('memberSubscriptionsModal'));
|
|
await window.app.loadData();
|
|
|
|
} catch (error) {
|
|
this.handleError(error, 'Failed to save subscription changes');
|
|
} finally {
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle API errors
|
|
*/
|
|
handleError(error, defaultMessage = 'An error occurred') {
|
|
let message = defaultMessage;
|
|
|
|
if (error instanceof APIError) {
|
|
if (error.isAuthError()) {
|
|
message = 'Authentication failed. Please check your API token.';
|
|
window.app.logout();
|
|
} else if (error.isBadRequest()) {
|
|
message = error.message || 'Invalid request data';
|
|
} else if (error.isNotFound()) {
|
|
message = 'Resource not found';
|
|
} else if (error.isServerError()) {
|
|
message = 'Server error. Please try again later.';
|
|
} else {
|
|
message = error.message || defaultMessage;
|
|
}
|
|
} else {
|
|
message = error.message || defaultMessage;
|
|
}
|
|
|
|
this.showNotification(message, 'error');
|
|
console.error('Error:', error);
|
|
}
|
|
|
|
/**
|
|
* Create action button
|
|
*/
|
|
createActionButton(text, icon, className, onClick) {
|
|
const button = document.createElement('button');
|
|
button.className = `btn btn-sm ${className}`;
|
|
button.innerHTML = `<i class="fas fa-${icon}"></i> ${text}`;
|
|
button.addEventListener('click', onClick);
|
|
return button;
|
|
}
|
|
|
|
/**
|
|
* Create status badge
|
|
*/
|
|
createStatusBadge(active) {
|
|
const badge = document.createElement('span');
|
|
badge.className = `status-badge ${active ? 'active' : 'inactive'}`;
|
|
badge.innerHTML = `
|
|
<i class="fas fa-${active ? 'check' : 'times'}"></i>
|
|
${active ? 'Active' : 'Inactive'}
|
|
`;
|
|
return badge;
|
|
}
|
|
|
|
/**
|
|
* Format email as mailto link
|
|
*/
|
|
createEmailLink(email) {
|
|
const link = document.createElement('a');
|
|
link.href = `mailto:${email}`;
|
|
link.textContent = email;
|
|
link.style.color = 'var(--primary-color)';
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* Show bulk import modal
|
|
*/
|
|
showBulkImportModal() {
|
|
this.resetBulkImportModal();
|
|
this.showModal(document.getElementById('bulkImportModal'));
|
|
}
|
|
|
|
/**
|
|
* Reset bulk import modal state
|
|
*/
|
|
resetBulkImportModal() {
|
|
// Reset to step 1
|
|
document.querySelectorAll('.import-step').forEach(step => {
|
|
step.style.display = 'none';
|
|
});
|
|
document.getElementById('importStep1').style.display = 'block';
|
|
|
|
// Reset button states
|
|
document.getElementById('bulkImportBackBtn').style.display = 'none';
|
|
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
|
|
document.getElementById('bulkImportNextBtn').disabled = true;
|
|
document.getElementById('bulkImportBtn').style.display = 'none';
|
|
document.getElementById('bulkImportDoneBtn').style.display = 'none';
|
|
|
|
// Clear file input
|
|
document.getElementById('csvFileInput').value = '';
|
|
document.getElementById('fileInfo').style.display = 'none';
|
|
|
|
// Reset parsed data
|
|
this.csvData = null;
|
|
this.parsedRows = [];
|
|
this.currentStep = 1;
|
|
}
|
|
|
|
/**
|
|
* Initialize bulk import event listeners
|
|
*/
|
|
initializeBulkImportListeners() {
|
|
const fileUploadArea = document.getElementById('fileUploadArea');
|
|
const csvFileInput = document.getElementById('csvFileInput');
|
|
const nextBtn = document.getElementById('bulkImportNextBtn');
|
|
const backBtn = document.getElementById('bulkImportBackBtn');
|
|
const importBtn = document.getElementById('bulkImportBtn');
|
|
const doneBtn = document.getElementById('bulkImportDoneBtn');
|
|
const removeFileBtn = document.getElementById('removeFileBtn');
|
|
|
|
// File upload area click
|
|
fileUploadArea.addEventListener('click', () => {
|
|
csvFileInput.click();
|
|
});
|
|
|
|
// File input change
|
|
csvFileInput.addEventListener('change', (e) => {
|
|
this.handleFileSelection(e.target.files[0]);
|
|
});
|
|
|
|
// Remove file button
|
|
removeFileBtn.addEventListener('click', () => {
|
|
this.removeSelectedFile();
|
|
});
|
|
|
|
// Drag and drop
|
|
fileUploadArea.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
fileUploadArea.classList.add('dragover');
|
|
});
|
|
|
|
fileUploadArea.addEventListener('dragleave', () => {
|
|
fileUploadArea.classList.remove('dragover');
|
|
});
|
|
|
|
fileUploadArea.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
fileUploadArea.classList.remove('dragover');
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
this.handleFileSelection(files[0]);
|
|
}
|
|
});
|
|
|
|
// Navigation buttons
|
|
nextBtn.addEventListener('click', () => {
|
|
this.handleBulkImportNext();
|
|
});
|
|
|
|
backBtn.addEventListener('click', () => {
|
|
this.handleBulkImportBack();
|
|
});
|
|
|
|
importBtn.addEventListener('click', () => {
|
|
this.handleBulkImportSubmit();
|
|
});
|
|
|
|
doneBtn.addEventListener('click', () => {
|
|
this.closeModal(document.getElementById('bulkImportModal'));
|
|
// Reload data to show imported members
|
|
if (window.app) {
|
|
window.app.loadData();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle file selection
|
|
*/
|
|
handleFileSelection(file) {
|
|
if (!file) return;
|
|
|
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
|
this.showNotification('Please select a CSV file', 'error');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
this.csvData = e.target.result;
|
|
this.displayFileInfo(file);
|
|
this.parseCSVPreview();
|
|
document.getElementById('bulkImportNextBtn').disabled = false;
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
/**
|
|
* Display file information
|
|
*/
|
|
displayFileInfo(file) {
|
|
document.getElementById('fileName').textContent = file.name;
|
|
document.getElementById('fileSize').textContent = `(${this.formatFileSize(file.size)})`;
|
|
document.getElementById('fileInfo').style.display = 'flex';
|
|
}
|
|
|
|
/**
|
|
* Remove selected file
|
|
*/
|
|
removeSelectedFile() {
|
|
document.getElementById('csvFileInput').value = '';
|
|
document.getElementById('fileInfo').style.display = 'none';
|
|
document.getElementById('bulkImportNextBtn').disabled = true;
|
|
this.csvData = null;
|
|
this.parsedRows = [];
|
|
}
|
|
|
|
/**
|
|
* Format file size in human readable format
|
|
*/
|
|
formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Parse CSV for preview
|
|
*/
|
|
parseCSVPreview() {
|
|
if (!this.csvData) return;
|
|
|
|
const lines = this.csvData.trim().split('\n');
|
|
if (lines.length < 2) {
|
|
this.showNotification('CSV file must contain at least a header and one data row', 'error');
|
|
return;
|
|
}
|
|
|
|
// Parse header
|
|
const headers = this.parseCSVLine(lines[0]);
|
|
const nameIndex = headers.findIndex(h => h.toLowerCase().includes('name'));
|
|
const emailIndex = headers.findIndex(h => h.toLowerCase().includes('email'));
|
|
|
|
if (nameIndex === -1 || emailIndex === -1) {
|
|
this.showNotification('CSV must contain Name and Email columns', 'error');
|
|
return;
|
|
}
|
|
|
|
this.parsedRows = [];
|
|
this.csvHeaders = headers;
|
|
let validRows = 0;
|
|
let errorRows = 0;
|
|
|
|
// Parse data rows
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const rawLine = lines[i].trim();
|
|
if (!rawLine) continue; // Skip empty lines
|
|
|
|
const values = this.parseCSVLine(rawLine);
|
|
const name = (values[nameIndex] || '').trim();
|
|
const email = (values[emailIndex] || '').trim();
|
|
|
|
// Determine error type and message
|
|
let error = null;
|
|
let isValid = true;
|
|
|
|
if (!email) {
|
|
error = 'Missing email address';
|
|
isValid = false;
|
|
} else if (!this.isValidEmail(email)) {
|
|
error = 'Invalid email format';
|
|
isValid = false;
|
|
}
|
|
// Note: Missing name is OK - we'll use the email as display name if needed
|
|
|
|
const row = {
|
|
index: i,
|
|
name,
|
|
email,
|
|
valid: isValid,
|
|
error,
|
|
rawLine,
|
|
parsedValues: values,
|
|
headers: headers
|
|
};
|
|
|
|
this.parsedRows.push(row);
|
|
if (isValid) validRows++;
|
|
else errorRows++;
|
|
}
|
|
|
|
// Update preview stats
|
|
document.getElementById('totalRowsCount').textContent = this.parsedRows.length;
|
|
document.getElementById('validRowsCount').textContent = validRows;
|
|
document.getElementById('errorRowsCount').textContent = errorRows;
|
|
}
|
|
|
|
/**
|
|
* Parse a CSV line handling quoted values
|
|
*/
|
|
parseCSVLine(line) {
|
|
const result = [];
|
|
let current = '';
|
|
let inQuotes = false;
|
|
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes;
|
|
} else if (char === ',' && !inQuotes) {
|
|
result.push(current.trim());
|
|
current = '';
|
|
} else {
|
|
current += char;
|
|
}
|
|
}
|
|
|
|
result.push(current.trim());
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Simple email validation
|
|
*/
|
|
isValidEmail(email) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
}
|
|
|
|
/**
|
|
* Handle next button click
|
|
*/
|
|
handleBulkImportNext() {
|
|
if (this.currentStep === 1) {
|
|
this.showStep2Preview();
|
|
} else if (this.currentStep === 2) {
|
|
this.showStep3ListSelection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle back button click
|
|
*/
|
|
handleBulkImportBack() {
|
|
if (this.currentStep === 2) {
|
|
this.showStep1Upload();
|
|
} else if (this.currentStep === 3) {
|
|
this.showStep2Preview();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show step 1 (upload)
|
|
*/
|
|
showStep1Upload() {
|
|
document.querySelectorAll('.import-step').forEach(step => {
|
|
step.style.display = 'none';
|
|
});
|
|
document.getElementById('importStep1').style.display = 'block';
|
|
|
|
document.getElementById('bulkImportBackBtn').style.display = 'none';
|
|
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
|
|
document.getElementById('bulkImportBtn').style.display = 'none';
|
|
|
|
this.currentStep = 1;
|
|
}
|
|
|
|
/**
|
|
* Show step 2 (preview)
|
|
*/
|
|
showStep2Preview() {
|
|
document.querySelectorAll('.import-step').forEach(step => {
|
|
step.style.display = 'none';
|
|
});
|
|
document.getElementById('importStep2').style.display = 'block';
|
|
|
|
document.getElementById('bulkImportBackBtn').style.display = 'inline-block';
|
|
document.getElementById('bulkImportNextBtn').style.display = 'inline-block';
|
|
document.getElementById('bulkImportBtn').style.display = 'none';
|
|
|
|
this.renderPreviewTable();
|
|
this.currentStep = 2;
|
|
}
|
|
|
|
/**
|
|
* Show step 3 (list selection)
|
|
*/
|
|
showStep3ListSelection() {
|
|
document.querySelectorAll('.import-step').forEach(step => {
|
|
step.style.display = 'none';
|
|
});
|
|
document.getElementById('importStep3').style.display = 'block';
|
|
|
|
document.getElementById('bulkImportBackBtn').style.display = 'inline-block';
|
|
document.getElementById('bulkImportNextBtn').style.display = 'none';
|
|
document.getElementById('bulkImportBtn').style.display = 'inline-block';
|
|
|
|
this.renderListSelection();
|
|
this.currentStep = 3;
|
|
}
|
|
|
|
/**
|
|
* Render preview table
|
|
*/
|
|
renderPreviewTable() {
|
|
const tbody = document.getElementById('previewTableBody');
|
|
tbody.innerHTML = '';
|
|
|
|
// Show first 10 rows for preview
|
|
const previewRows = this.parsedRows.slice(0, 10);
|
|
|
|
previewRows.forEach(row => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td>${row.index}</td>
|
|
<td>${this.escapeHtml(row.name)}</td>
|
|
<td>${this.escapeHtml(row.email)}</td>
|
|
<td>
|
|
<span class="row-status ${row.valid ? 'valid' : 'error'}">
|
|
${row.valid ? 'Valid' : 'Error'}
|
|
</span>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
// Show errors if any
|
|
const errors = this.parsedRows.filter(row => !row.valid);
|
|
if (errors.length > 0) {
|
|
document.getElementById('errorList').style.display = 'block';
|
|
this.renderErrorDisplay(errors);
|
|
} else {
|
|
document.getElementById('errorList').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render error display with expandable details
|
|
*/
|
|
renderErrorDisplay(errors) {
|
|
// Update error summary
|
|
const errorSummary = document.getElementById('errorSummary');
|
|
const errorTypes = {};
|
|
|
|
errors.forEach(error => {
|
|
const type = error.error;
|
|
errorTypes[type] = (errorTypes[type] || 0) + 1;
|
|
});
|
|
|
|
const summaryText = Object.entries(errorTypes)
|
|
.map(([type, count]) => `${count} ${type.toLowerCase()}`)
|
|
.join(', ');
|
|
|
|
errorSummary.innerHTML = `
|
|
<p><strong>${errors.length}</strong> rows have issues: ${summaryText}</p>
|
|
<p class="text-muted text-sm">These rows will be skipped during import. Click "Show All" to see details.</p>
|
|
`;
|
|
|
|
// Render error table
|
|
const errorTableBody = document.getElementById('errorTableBody');
|
|
errorTableBody.innerHTML = '';
|
|
|
|
errors.forEach(row => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td class="font-medium">${row.index + 1}</td>
|
|
<td class="font-medium">${this.escapeHtml(row.error)}</td>
|
|
<td>${this.escapeHtml(row.name || '-')}</td>
|
|
<td>${this.escapeHtml(row.email || '-')}</td>
|
|
<td class="raw-data-cell" title="${this.escapeHtml(row.rawLine)}">${this.escapeHtml(row.rawLine)}</td>
|
|
`;
|
|
errorTableBody.appendChild(tr);
|
|
});
|
|
|
|
// Setup toggle functionality
|
|
this.setupErrorToggle();
|
|
}
|
|
|
|
/**
|
|
* Setup error toggle functionality
|
|
*/
|
|
setupErrorToggle() {
|
|
const toggleBtn = document.getElementById('toggleErrorsBtn');
|
|
const errorDetails = document.getElementById('errorDetails');
|
|
const toggleText = document.getElementById('errorToggleText');
|
|
const toggleIcon = document.getElementById('errorToggleIcon');
|
|
|
|
// Remove existing listener to prevent duplicates
|
|
const newToggleBtn = toggleBtn.cloneNode(true);
|
|
toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
|
|
|
|
newToggleBtn.addEventListener('click', () => {
|
|
const isHidden = errorDetails.style.display === 'none';
|
|
|
|
if (isHidden) {
|
|
errorDetails.style.display = 'block';
|
|
toggleText.textContent = 'Hide Details';
|
|
toggleIcon.className = 'fas fa-chevron-up';
|
|
} else {
|
|
errorDetails.style.display = 'none';
|
|
toggleText.textContent = 'Show All';
|
|
toggleIcon.className = 'fas fa-chevron-down';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Render list selection checkboxes
|
|
*/
|
|
renderListSelection() {
|
|
const container = document.getElementById('listSelection');
|
|
container.innerHTML = '';
|
|
|
|
if (!window.app || !window.app.lists) {
|
|
container.innerHTML = '<p class="text-muted">No mailing lists available</p>';
|
|
return;
|
|
}
|
|
|
|
window.app.lists.forEach(list => {
|
|
const div = document.createElement('div');
|
|
div.className = 'list-checkbox';
|
|
div.innerHTML = `
|
|
<input type="checkbox" id="list_${list.list_id}" value="${list.list_id}">
|
|
<div class="list-checkbox-label">
|
|
<span class="list-checkbox-name">${this.escapeHtml(list.list_name)}</span>
|
|
<span class="list-checkbox-email">${this.escapeHtml(list.list_email)}</span>
|
|
</div>
|
|
`;
|
|
container.appendChild(div);
|
|
|
|
// Make the whole div clickable
|
|
div.addEventListener('click', (e) => {
|
|
if (e.target.type !== 'checkbox') {
|
|
const checkbox = div.querySelector('input[type="checkbox"]');
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle bulk import submission
|
|
*/
|
|
async handleBulkImportSubmit() {
|
|
const selectedLists = Array.from(document.querySelectorAll('#listSelection input[type="checkbox"]:checked'))
|
|
.map(cb => parseInt(cb.value));
|
|
|
|
if (selectedLists.length === 0) {
|
|
this.showNotification('Please select at least one mailing list', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.setLoading(true);
|
|
|
|
const result = await apiClient.bulkImportMembers(this.csvData, selectedLists);
|
|
|
|
this.showStep4Results(result);
|
|
|
|
} catch (error) {
|
|
this.handleError(error, 'Bulk import failed');
|
|
} finally {
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show step 4 (results)
|
|
*/
|
|
showStep4Results(result) {
|
|
document.querySelectorAll('.import-step').forEach(step => {
|
|
step.style.display = 'none';
|
|
});
|
|
document.getElementById('importStep4').style.display = 'block';
|
|
|
|
document.getElementById('bulkImportBackBtn').style.display = 'none';
|
|
document.getElementById('bulkImportNextBtn').style.display = 'none';
|
|
document.getElementById('bulkImportBtn').style.display = 'none';
|
|
document.getElementById('bulkImportDoneBtn').style.display = 'inline-block';
|
|
|
|
// Update result stats
|
|
document.getElementById('processedCount').textContent = result.processed_rows;
|
|
document.getElementById('createdCount').textContent = result.created_members;
|
|
document.getElementById('updatedCount').textContent = result.updated_members;
|
|
document.getElementById('subscriptionsCount').textContent = result.subscriptions_added;
|
|
|
|
// Show errors if any
|
|
if (result.errors && result.errors.length > 0) {
|
|
document.getElementById('resultErrors').style.display = 'block';
|
|
const errorList = document.getElementById('resultErrorList');
|
|
errorList.innerHTML = '';
|
|
|
|
result.errors.forEach(error => {
|
|
const li = document.createElement('li');
|
|
li.textContent = error;
|
|
errorList.appendChild(li);
|
|
});
|
|
} else {
|
|
document.getElementById('resultErrors').style.display = 'none';
|
|
}
|
|
|
|
this.currentStep = 4;
|
|
|
|
// Show success notification
|
|
this.showNotification(
|
|
`Successfully imported ${result.processed_rows} members with ${result.subscriptions_added} subscriptions`,
|
|
'success'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
}
|
|
|
|
// Create global UI manager instance
|
|
window.uiManager = new UIManager(); |