/** * 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(); }); document.getElementById('addUserBtn').addEventListener('click', () => { this.showUserModal(); }); // 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(); }); // Bounce history modal document.getElementById('bounceHistoryModalClose').addEventListener('click', () => { this.closeModal(document.getElementById('bounceHistoryModal')); }); document.getElementById('bounceHistoryCloseBtn').addEventListener('click', () => { this.closeModal(document.getElementById('bounceHistoryModal')); }); document.getElementById('bounceHistoryResetBtn').addEventListener('click', () => { this.handleBounceStatusReset(); }); // 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(); }); document.getElementById('userForm').addEventListener('submit', (e) => { e.preventDefault(); this.handleUserFormSubmit(); }); // 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')); }); // Bounce history modal document.getElementById('bounceHistoryModalClose').addEventListener('click', () => { this.closeModal(document.getElementById('bounceHistoryModal')); }); document.getElementById('bounceHistoryCloseBtn').addEventListener('click', () => { this.closeModal(document.getElementById('bounceHistoryModal')); }); document.getElementById('bounceHistoryResetBtn').addEventListener('click', () => { this.handleResetBounceStatus(); }); } /** * 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 user modal (add/edit) */ showUserModal(userData = null) { const modal = document.getElementById('userModal'); const title = document.getElementById('userModalTitle'); const form = document.getElementById('userForm'); const passwordHelp = document.getElementById('passwordHelp'); const passwordField = document.getElementById('userPassword'); if (userData) { // Edit mode title.textContent = 'Edit User'; document.getElementById('userName').value = userData.username; document.getElementById('userName').readOnly = true; // Can't change username passwordField.placeholder = 'Leave blank to keep current password'; passwordField.required = false; passwordHelp.style.display = 'block'; document.getElementById('userRole').value = userData.role; document.getElementById('userActive').checked = userData.active; this.currentEditingItem = userData; } else { // Add mode title.textContent = 'Add User'; form.reset(); document.getElementById('userName').readOnly = false; passwordField.placeholder = 'Password'; passwordField.required = true; passwordHelp.style.display = 'none'; document.getElementById('userActive').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 = ''; memberSelect.innerHTML = ''; 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); } } /** * Handle user form submission */ async handleUserFormSubmit() { const form = document.getElementById('userForm'); const formData = new FormData(form); const userData = { username: formData.get('userName'), role: formData.get('userRole'), active: formData.get('userActive') === 'on' }; // Only include password if it's provided (for updates, empty means no change) const password = formData.get('userPassword'); if (password) { userData.password = password; } try { this.setLoading(true); if (this.currentEditingItem) { // Update existing user await apiClient.updateUser(this.currentEditingItem.user_id, userData); this.showNotification('User updated successfully', 'success'); } else { // Create new user if (!userData.password) { throw new Error('Password is required for new users'); } await apiClient.createUser(userData); this.showNotification('User created successfully', 'success'); } this.closeModal(document.getElementById('userModal')); await window.app.loadData(); } catch (error) { this.handleError(error, 'Failed to save user'); } 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 = `
${this.escapeHtml(list.list_name)}
${this.escapeHtml(list.list_email)}
${isSubscribed ? 'Subscribed' : 'Not subscribed'}
`; // 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 ? ` Save Changes (${this.subscriptionChanges.size})` : ` 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); } } /** * Show bounce history modal for a member */ async showBounceHistoryModal(member) { const modal = document.getElementById('bounceHistoryModal'); // Update member info document.getElementById('bounceHistoryTitle').textContent = `Bounce History - ${member.name}`; document.getElementById('bounceHistoryMemberName').textContent = member.name; document.getElementById('bounceHistoryMemberEmail').textContent = member.email; // Update summary stats document.getElementById('bounceTotalCount').textContent = member.bounce_count || 0; document.getElementById('bounceLastDate').textContent = this.formatDateTime(member.last_bounce_at); const statusText = document.getElementById('bounceStatusText'); statusText.className = 'bounce-stat-value'; if (member.bounce_status === 'hard_bounce') { statusText.textContent = 'Hard Bounce'; statusText.classList.add('text-danger'); } else if (member.bounce_status === 'soft_bounce') { statusText.textContent = 'Soft Bounce'; statusText.classList.add('text-warning'); } else { statusText.textContent = 'Clean'; statusText.classList.add('text-success'); } try { this.currentMemberForBounces = member; // Load bounce history const bounces = await apiClient.getMemberBounces(member.member_id); this.renderBounceHistory(bounces); this.showModal(modal); } catch (error) { this.handleError(error, 'Failed to load bounce history'); } } /** * Render bounce history list */ renderBounceHistory(bounces) { const container = document.getElementById('bounceHistoryList'); container.innerHTML = ''; if (bounces.length === 0) { container.innerHTML = `

No bounces recorded for this member

`; return; } bounces.forEach(bounce => { const item = document.createElement('div'); item.className = 'bounce-history-item'; let typeClass = 'bounce-type-'; if (bounce.bounce_type === 'Permanent') { typeClass += 'permanent'; } else if (bounce.bounce_type === 'Transient') { typeClass += 'transient'; } else { typeClass += 'undetermined'; } item.innerHTML = `
${bounce.bounce_type} ${this.formatDateTime(bounce.timestamp)}
${bounce.bounce_subtype ? `
Subtype: ${this.escapeHtml(bounce.bounce_subtype)}
` : ''} ${bounce.diagnostic_code ? `
${this.escapeHtml(bounce.diagnostic_code)}
` : ''} `; container.appendChild(item); }); } /** * Handle bounce status reset */ async handleResetBounceStatus() { if (!this.currentMemberForBounces) return; this.showConfirmation( `Are you sure you want to reset the bounce status for "${this.currentMemberForBounces.name}"? This will clear the bounce count and allow emails to be sent to this address again.`, async () => { try { this.setLoading(true); await apiClient.resetBounceStatus(this.currentMemberForBounces.member_id); this.showNotification('Bounce status reset successfully', 'success'); this.closeModal(document.getElementById('bounceHistoryModal')); await window.app.loadData(); } catch (error) { this.handleError(error, 'Failed to reset bounce status'); } 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 = ` ${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 = ` ${active ? 'Active' : 'Inactive'} `; return badge; } /** * Create bounce status badge */ createBounceStatusBadge(bounceStatus, bounceCount) { const badge = document.createElement('span'); if (bounceStatus === 'hard_bounce') { badge.className = 'bounce-badge bounce-hard'; badge.innerHTML = ` Hard Bounce`; badge.title = `${bounceCount} bounce(s) - Email permanently failed`; } else if (bounceStatus === 'soft_bounce') { badge.className = 'bounce-badge bounce-soft'; badge.innerHTML = ` Soft Bounce`; badge.title = `${bounceCount} bounce(s) - Temporary delivery issues`; } else if (bounceCount > 0) { badge.className = 'bounce-badge bounce-warning'; badge.innerHTML = ` ${bounceCount} bounce(s)`; badge.title = `${bounceCount} bounce(s) recorded`; } else { return null; // No badge for clean status } return badge; } /** * Format date and time */ formatDateTime(dateString) { if (!dateString) return 'Never'; const date = new Date(dateString); return date.toLocaleString(); } /** * Format date only */ formatDate(dateString) { if (!dateString) return 'Never'; const date = new Date(dateString); return date.toLocaleDateString(); } /** * 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 = ` ${row.index} ${this.escapeHtml(row.name)} ${this.escapeHtml(row.email)} ${row.valid ? 'Valid' : 'Error'} `; 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 = `

${errors.length} rows have issues: ${summaryText}

These rows will be skipped during import. Click "Show All" to see details.

`; // Render error table const errorTableBody = document.getElementById('errorTableBody'); errorTableBody.innerHTML = ''; errors.forEach(row => { const tr = document.createElement('tr'); tr.innerHTML = ` ${row.index + 1} ${this.escapeHtml(row.error)} ${this.escapeHtml(row.name || '-')} ${this.escapeHtml(row.email || '-')} ${this.escapeHtml(row.rawLine)} `; 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 = '

No mailing lists available

'; return; } window.app.lists.forEach(list => { const div = document.createElement('div'); div.className = 'list-checkbox'; div.innerHTML = `
${this.escapeHtml(list.list_name)} ${this.escapeHtml(list.list_email)}
`; 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();