From 2bdcbb17d9f47be523aeae3600a40d7d97a93b5b Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Tue, 6 May 2025 12:19:59 -0500 Subject: [PATCH] mulk email import n export --- backend/package.json | 3 +- backend/src/routes/mailingListAdmin.js | 295 ++++------ .../src/components/CookieConsentPopup.jsx | 2 +- frontend/src/hooks/emailCampaignHooks.js | 22 +- frontend/src/hooks/reduxHooks.js | 2 - frontend/src/pages/Admin/MailingListsPage.jsx | 68 +-- frontend/src/pages/Admin/SubscribersPage.jsx | 543 +++++++++++++++++- 7 files changed, 676 insertions(+), 259 deletions(-) diff --git a/backend/package.json b/backend/package.json index e5b4175..b40c407 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,7 +26,8 @@ "pg-hstore": "^2.3.4", "slugify": "^1.6.6", "stripe": "^12.0.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "xlsx": "^0.18.5" }, "devDependencies": { "nodemon": "^2.0.22" diff --git a/backend/src/routes/mailingListAdmin.js b/backend/src/routes/mailingListAdmin.js index 3e7a267..b4d52aa 100644 --- a/backend/src/routes/mailingListAdmin.js +++ b/backend/src/routes/mailingListAdmin.js @@ -470,13 +470,12 @@ module.exports = (pool, query, authMiddleware) => { }); /** - * Import subscribers to a mailing list from CSV + * Import subscribers to a mailing list from JSON data * POST /api/admin/mailing-lists/import - */ - router.post('/import', upload.single('file'), async (req, res, next) => { + */ + router.post('/import', async (req, res, next) => { try { - const { listId } = req.body; - const file = req.file; + const { listId, subscribers } = req.body; if (!req.user.is_admin) { return res.status(403).json({ @@ -492,10 +491,10 @@ module.exports = (pool, query, authMiddleware) => { }); } - if (!file) { + if (!subscribers || !Array.isArray(subscribers) || subscribers.length === 0) { return res.status(400).json({ error: true, - message: 'CSV file is required' + message: 'Subscribers array is required and cannot be empty' }); } @@ -512,128 +511,95 @@ module.exports = (pool, query, authMiddleware) => { }); } - // Process the CSV file - const results = []; - // const processFile = () => { - // return new Promise((resolve, reject) => { - // fs.createReadStream(file.path) - // .pipe(csv()) - // .on('data', (data) => results.push(data)) - // .on('end', () => { - // // Clean up temp file - // fs.unlink(file.path, (err) => { - // if (err) console.error('Error deleting temp file:', err); - // }); - // resolve(results); - // }) - // .on('error', reject); - // }); - // }; - const processFile = () => { - return new Promise((resolve, reject) => { - // For S3 uploads, file.path won't exist, but file.location will - const filePath = file.path || file.location; - - // If S3 storage, we need to download the file first - if (!file.path && file.location) { - // Implementation for S3 file would go here - // For now, we'll reject as this would need a different approach - reject(new Error('S3 file processing not implemented')); - return; - } - - // Local file processing - fs.createReadStream(file.path) - .pipe(csv()) - .on('data', (data) => results.push(data)) - .on('end', () => { - // Clean up temp file - only for local storage - if (file.path) { - fs.unlink(file.path, (err) => { - if (err) console.error('Error deleting temp file:', err); - }); - } - resolve(results); - }) - .on('error', reject); - }); - }; - - const subscribers = await processFile(); - - // Validate and import subscribers - if (subscribers.length === 0) { - return res.status(400).json({ - error: true, - message: 'CSV file is empty or has invalid format' - }); - } - // Begin transaction const client = await pool.connect(); let importedCount = 0; let errorCount = 0; + const errors = []; + const imported = []; try { await client.query('BEGIN'); - for (const data of subscribers) { - try { - // Validate email - const email = data.email; - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - errorCount++; - continue; - } - - // Check if subscriber already exists - let subscriberId; - const subscriberCheck = await client.query( - 'SELECT id FROM subscribers WHERE email = $1', - [email] - ); - - if (subscriberCheck.rows.length > 0) { - // Update existing subscriber - subscriberId = subscriberCheck.rows[0].id; - await client.query( - `UPDATE subscribers - SET first_name = COALESCE($1, first_name), - last_name = COALESCE($2, last_name), - status = COALESCE($3, status), - updated_at = NOW() - WHERE id = $4`, - [data.first_name, data.last_name, data.status || 'active', subscriberId] + // Process subscribers in batches to avoid overwhelming the database + const batchSize = 50; + + for (let i = 0; i < subscribers.length; i += batchSize) { + const batch = subscribers.slice(i, i + batchSize); + + // Process each subscriber in the batch + for (const data of batch) { + try { + // Validate email + const email = data.email; + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errorCount++; + errors.push({ + email: email || 'Invalid email', + reason: 'Invalid email format' + }); + continue; + } + + // Check if subscriber already exists + let subscriberId; + const subscriberCheck = await client.query( + 'SELECT id FROM subscribers WHERE email = $1', + [email] ); - } else { - // Create new subscriber - subscriberId = uuidv4(); - await client.query( - `INSERT INTO subscribers (id, email, first_name, last_name, status) - VALUES ($1, $2, $3, $4, $5)`, - [subscriberId, email, data.first_name || null, data.last_name || null, data.status || 'active'] - ); - } - - // Check if subscriber is already on this list - const listSubscriberCheck = await client.query( - 'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2', - [listId, subscriberId] - ); - - if (listSubscriberCheck.rows.length === 0) { - // Add subscriber to list - await client.query( - `INSERT INTO mailing_list_subscribers (list_id, subscriber_id) - VALUES ($1, $2)`, + + if (subscriberCheck.rows.length > 0) { + // Update existing subscriber + subscriberId = subscriberCheck.rows[0].id; + await client.query( + `UPDATE subscribers + SET first_name = COALESCE($1, first_name), + last_name = COALESCE($2, last_name), + status = COALESCE($3, status), + updated_at = NOW() + WHERE id = $4`, + [data.first_name, data.last_name, data.status || 'active', subscriberId] + ); + } else { + // Create new subscriber + subscriberId = uuidv4(); + await client.query( + `INSERT INTO subscribers (id, email, first_name, last_name, status) + VALUES ($1, $2, $3, $4, $5)`, + [subscriberId, email, data.first_name || null, data.last_name || null, data.status || 'active'] + ); + } + + // Check if subscriber is already on this list + const listSubscriberCheck = await client.query( + 'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2', [listId, subscriberId] ); + + if (listSubscriberCheck.rows.length === 0) { + // Add subscriber to list + await client.query( + `INSERT INTO mailing_list_subscribers (list_id, subscriber_id) + VALUES ($1, $2)`, + [listId, subscriberId] + ); + } + + importedCount++; + imported.push({ + email, + first_name: data.first_name || null, + last_name: data.last_name || null, + status: data.status || 'active' + }); + } catch (err) { + console.error('Error importing subscriber:', err); + errorCount++; + errors.push({ + email: data.email || 'Unknown', + reason: err.message || 'Database error' + }); } - - importedCount++; - } catch (err) { - console.error('Error importing subscriber:', err); - errorCount++; } } @@ -644,6 +610,8 @@ module.exports = (pool, query, authMiddleware) => { message: `Import completed with ${importedCount} subscribers added and ${errorCount} errors`, importedCount, errorCount, + imported, + errors, listId }); } catch (error) { @@ -653,20 +621,14 @@ module.exports = (pool, query, authMiddleware) => { client.release(); } } catch (error) { - // Clean up temp file on error - if (req.file) { - fs.unlink(req.file.path, (err) => { - if (err) console.error('Error deleting temp file:', err); - }); - } next(error); } }); - + /** - * Export subscribers from a mailing list as CSV + * Export subscribers from a mailing list as Excel * GET /api/admin/mailing-lists/:id/export - */ + */ router.get('/:id/export', async (req, res, next) => { try { const { id } = req.params; @@ -711,55 +673,48 @@ module.exports = (pool, query, authMiddleware) => { const subscribersResult = await query(subscribersQuery, [id]); const subscribers = subscribersResult.rows; - // Create a temp file for CSV - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`; - const tempDir = path.join(__dirname, '../uploads/temp'); - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir, { recursive: true }); - } - const filePath = path.join(__dirname, '../uploads/temp', fileName); + // Create Excel workbook and worksheet + const XLSX = require('xlsx'); + const workbook = XLSX.utils.book_new(); - // Create CSV writer - const csvWriter = createObjectCsvWriter({ - path: filePath, - header: [ - { id: 'email', title: 'Email' }, - { id: 'first_name', title: 'First Name' }, - { id: 'last_name', title: 'Last Name' }, - { id: 'status', title: 'Status' }, - { id: 'subscribed_at', title: 'Subscribed At' }, - { id: 'last_activity_at', title: 'Last Activity' } - ] - }); - - // Format dates in the data + // Format subscribers data for Excel const formattedSubscribers = subscribers.map(sub => ({ - ...sub, - subscribed_at: sub.subscribed_at ? new Date(sub.subscribed_at).toISOString() : '', - last_activity_at: sub.last_activity_at ? new Date(sub.last_activity_at).toISOString() : '' + Email: sub.email, + 'First Name': sub.first_name || '', + 'Last Name': sub.last_name || '', + Status: sub.status || 'active', + 'Subscribed At': sub.subscribed_at ? new Date(sub.subscribed_at).toISOString() : '', + 'Last Activity': sub.last_activity_at ? new Date(sub.last_activity_at).toISOString() : '' })); - // Write to CSV - await csvWriter.writeRecords(formattedSubscribers); + // Create worksheet + const worksheet = XLSX.utils.json_to_sheet(formattedSubscribers); - // Send file and delete after sending - res.download(filePath, fileName, (err) => { - // Delete the temp file after sending - fs.unlink(filePath, (unlinkErr) => { - if (unlinkErr) console.error('Error deleting temp file:', unlinkErr); - }); - - if (err) { - console.error('Error sending file:', err); - if (!res.headersSent) { - res.status(500).json({ - error: true, - message: 'Error sending file' - }); - } - } - }); + // Set column widths for better readability + const colWidths = [ + { wch: 30 }, // Email + { wch: 15 }, // First Name + { wch: 15 }, // Last Name + { wch: 10 }, // Status + { wch: 25 }, // Subscribed At + { wch: 25 } // Last Activity + ]; + worksheet['!cols'] = colWidths; + + // Add worksheet to workbook + XLSX.utils.book_append_sheet(workbook, worksheet, 'Subscribers'); + + // Generate timestamp for filename + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.xlsx`; + + // Set headers for Excel download + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + + // Generate and send Excel file + const excelBuffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }); + res.send(excelBuffer); } catch (error) { next(error); } diff --git a/frontend/src/components/CookieConsentPopup.jsx b/frontend/src/components/CookieConsentPopup.jsx index 49b4c6f..da53e01 100644 --- a/frontend/src/components/CookieConsentPopup.jsx +++ b/frontend/src/components/CookieConsentPopup.jsx @@ -68,7 +68,7 @@ const CookieConsentPopup = () => { }; // Reopen the consent popup (for testing or if user wants to change settings) - // This function can be exposed via a preferences button somewhere in your app + // This function can be exposed via a preferences button somewhere const reopenConsentPopup = () => { setOpen(true); }; diff --git a/frontend/src/hooks/emailCampaignHooks.js b/frontend/src/hooks/emailCampaignHooks.js index 14a250b..19dd9c2 100644 --- a/frontend/src/hooks/emailCampaignHooks.js +++ b/frontend/src/hooks/emailCampaignHooks.js @@ -424,11 +424,7 @@ export const useImportSubscribers = () => { return useMutation({ mutationFn: async (formData) => { - const response = await apiClient.post('/admin/mailing-lists/import', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }); + const response = await apiClient.post('/admin/mailing-lists/import', formData); return response.data; }, onSuccess: (data) => { @@ -460,12 +456,24 @@ export const useExportSubscribers = () => { responseType: 'blob' }); + // Get filename from Content-Disposition header if available + let filename = `subscribers-${listId}.xlsx`; + const contentDisposition = response.headers['content-disposition']; + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="(.+)"/); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + // Create a download link and trigger the download - const blob = new Blob([response.data], { type: 'text/csv' }); + const blob = new Blob([response.data], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `subscribers-${listId}.csv`; + a.download = filename; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); diff --git a/frontend/src/hooks/reduxHooks.js b/frontend/src/hooks/reduxHooks.js index dbc397a..dcbdb19 100644 --- a/frontend/src/hooks/reduxHooks.js +++ b/frontend/src/hooks/reduxHooks.js @@ -1,12 +1,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { useMemo } from 'react'; -// Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch = () => useDispatch(); export const useAppSelector = useSelector; -// Create a custom hook for notifications export const useNotification = () => { const dispatch = useAppDispatch(); diff --git a/frontend/src/pages/Admin/MailingListsPage.jsx b/frontend/src/pages/Admin/MailingListsPage.jsx index 0d2b800..dcce812 100644 --- a/frontend/src/pages/Admin/MailingListsPage.jsx +++ b/frontend/src/pages/Admin/MailingListsPage.jsx @@ -46,20 +46,16 @@ import { useMailingLists, useCreateMailingList, useUpdateMailingList, - useDeleteMailingList, - useImportSubscribers, - useExportSubscribers -} from '../../hooks/emailCampaignHooks'; + useDeleteMailingList +} from '@hooks/emailCampaignHooks'; const MailingListsPage = () => { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(''); const [newListDialogOpen, setNewListDialogOpen] = useState(false); const [editListData, setEditListData] = useState(null); - const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedListId, setSelectedListId] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [importFile, setImportFile] = useState(null); const [anchorEl, setAnchorEl] = useState(null); // Fetch all mailing lists @@ -69,8 +65,6 @@ const MailingListsPage = () => { const createList = useCreateMailingList(); const updateList = useUpdateMailingList(); const deleteList = useDeleteMailingList(); - const importSubscribers = useImportSubscribers(); - const exportSubscribers = useExportSubscribers(); // New list form state const [newListForm, setNewListForm] = useState({ @@ -182,42 +176,8 @@ const MailingListsPage = () => { } }; - // Handle file selection for import - const handleFileChange = (e) => { - if (e.target.files && e.target.files.length > 0) { - setImportFile(e.target.files[0]); - } - }; - // Handle subscriber import - const handleImport = async () => { - if (!importFile || !selectedListId) return; - - try { - const formData = new FormData(); - formData.append('file', importFile); - formData.append('listId', selectedListId); - - await importSubscribers.mutateAsync(formData); - setImportDialogOpen(false); - setImportFile(null); - handleMenuClose(); - } catch (error) { - console.error('Failed to import subscribers:', error); - } - }; - // Handle subscriber export - const handleExport = async () => { - if (!selectedListId) return; - - try { - await exportSubscribers.mutateAsync(selectedListId); - handleMenuClose(); - } catch (error) { - console.error('Failed to export subscribers:', error); - } - }; // Loading state if (isLoading) { @@ -300,18 +260,6 @@ const MailingListsPage = () => { Delete - { - setImportDialogOpen(true); - setSelectedListId(list.id); - handleMenuClose(); - }}> - - Import Subscribers - - - - Export Subscribers - navigate(`/admin/mailing-lists/${list.id}/subscribers`)}> View Subscribers @@ -393,18 +341,6 @@ const MailingListsPage = () => { - - {/* Import Subscribers Dialog */} - setImportDialogOpen(false)}> - Import Subscribers - - - - - - - - ); }; diff --git a/frontend/src/pages/Admin/SubscribersPage.jsx b/frontend/src/pages/Admin/SubscribersPage.jsx index 76008bc..7ad0776 100644 --- a/frontend/src/pages/Admin/SubscribersPage.jsx +++ b/frontend/src/pages/Admin/SubscribersPage.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { Box, Typography, @@ -15,6 +15,7 @@ import { Chip, Dialog, DialogTitle, + DialogContentText, DialogContent, DialogActions, TextField, @@ -31,7 +32,9 @@ import { InputLabel, Select, Breadcrumbs, - Link + Link, + Stack, + LinearProgress } from '@mui/material'; import { Add as AddIcon, @@ -43,21 +46,31 @@ import { Search as SearchIcon, Clear as ClearIcon, Block as BlockIcon, - CheckCircle as CheckCircleIcon + CheckCircle as CheckCircleIcon, + FileDownload as DownloadIcon, + Upload as UploadIcon, + CloudUpload, + CheckCircleOutline } from '@mui/icons-material'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import { format } from 'date-fns'; +import * as XLSX from 'xlsx'; import { useMailingList, useSubscribers, useAddSubscriber, useUpdateSubscriber, useDeleteSubscriber, - useSubscriberActivity + useSubscriberActivity, + useExportSubscribers, + useImportSubscribers } from '@hooks/emailCampaignHooks'; +import apiClient from '@services/api'; +import { useNotification } from '@hooks/reduxHooks'; const SubscribersPage = () => { const navigate = useNavigate(); + const notification = useNotification(); const { listId } = useParams(); const [searchTerm, setSearchTerm] = useState(''); const [page, setPage] = useState(0); @@ -69,6 +82,18 @@ const SubscribersPage = () => { const [editDialogOpen, setEditDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [activityDialogOpen, setActivityDialogOpen] = useState(false); + // New states for bulk import + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [uploadFile, setUploadFile] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadError, setUploadError] = useState(null); + const [parsedSubscribers, setParsedSubscribers] = useState([]); + const [isUploading, setIsUploading] = useState(false); + const [uploadSuccess, setUploadSuccess] = useState(false); + const [uploadResults, setUploadResults] = useState(null); + + const fileInputRef = useRef(null); + // New subscriber form data const [subscriberForm, setSubscriberForm] = useState({ email: '', @@ -91,6 +116,8 @@ const SubscribersPage = () => { // Mutations const addSubscriber = useAddSubscriber(); const updateSubscriber = useUpdateSubscriber(); + const importSubscribers = useImportSubscribers(); + const exportSubscribers = useExportSubscribers(); const deleteSubscriber = useDeleteSubscriber(); // Handle form input changes @@ -249,6 +276,265 @@ const SubscribersPage = () => { } }; + // Handle export subscribers + const handleExportSubscribers = async () => { + try { + if (!listId) { + notification.showNotification('No mailing list selected', 'error'); + return; + } + + // Call the export mutation + await exportSubscribers.mutateAsync(listId); + + // The notification is handled within the hook + } catch (error) { + console.error('Failed to export subscribers:', error); + notification.showNotification('Failed to export subscribers. Please try again.', 'error'); + } + }; + + // Handle open import dialog + const handleOpenImportDialog = () => { + resetUploadState(); + setImportDialogOpen(true); + }; + + // Handle close import dialog + const handleCloseImportDialog = () => { + resetUploadState(); + setImportDialogOpen(false); + }; + + // Reset upload states + const resetUploadState = () => { + setUploadFile(null); + setUploadProgress(0); + setUploadError(null); + setParsedSubscribers([]); + setIsUploading(false); + setUploadSuccess(false); + setUploadResults(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + // Handle file selection + const handleFileSelect = (e) => { + setUploadError(null); + setUploadSuccess(false); + const file = e.target.files[0]; + if (!file) return; + + setUploadFile(file); + + // Determine file type and parse accordingly + const fileExtension = file.name.split('.').pop().toLowerCase(); + + if (fileExtension === 'csv') { + parseCsvFile(file); + } else if (fileExtension === 'xlsx' || fileExtension === 'xls') { + parseExcelFile(file); + } else { + setUploadError('Unsupported file format. Please upload a CSV or Excel file.'); + } + }; + + // Parse CSV file + const parseCsvFile = (file) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const csv = e.target.result; + const lines = csv.split('\n'); + const headers = lines[0].split(',').map(header => header.trim()); + + // Check for required fields + if (!headers.includes('email')) { + setUploadError('CSV file must contain an "email" column'); + return; + } + + const subscribers = []; + + for (let i = 1; i < lines.length; i++) { + if (!lines[i].trim()) continue; + + const values = lines[i].split(',').map(value => value.trim()); + const subscriber = {}; + + headers.forEach((header, index) => { + subscriber[header] = values[index] || ''; + }); + + // Map CSV columns to subscriber fields + const mappedSubscriber = { + email: subscriber.email, + first_name: subscriber.first_name || subscriber.firstName || '', + last_name: subscriber.last_name || subscriber.lastName || '', + status: subscriber.status || 'active' + }; + + if (mappedSubscriber.email) { + subscribers.push(mappedSubscriber); + } + } + + setParsedSubscribers(subscribers); + } catch (error) { + setUploadError(`Error parsing CSV: ${error.message}`); + } + }; + reader.onerror = () => setUploadError('Error reading file'); + reader.readAsText(file); + }; + + // Parse Excel file + const parseExcelFile = (file) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = new Uint8Array(e.target.result); + const workbook = XLSX.read(data, { type: 'array' }); + + // Get first sheet + const firstSheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[firstSheetName]; + + // Convert to JSON + const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: '' }); + + if (jsonData.length === 0) { + setUploadError('No data found in the Excel file'); + return; + } + + // Check for required fields + const firstRow = jsonData[0]; + if (!('email' in firstRow) && !('Email' in firstRow)) { + setUploadError('Excel file must contain an "email" or "Email" column'); + return; + } + + // Map Excel columns to subscriber fields + const subscribers = jsonData.map(row => ({ + email: row.email || row.Email || '', + first_name: row.first_name || row.firstName || row['First Name'] || '', + last_name: row.last_name || row.lastName || row['Last Name'] || '', + status: row.status || row.Status || 'active' + })).filter(sub => sub.email); + + setParsedSubscribers(subscribers); + } catch (error) { + setUploadError(`Error parsing Excel file: ${error.message}`); + } + }; + reader.onerror = () => { + setUploadError('Error reading file'); + }; + reader.readAsArrayBuffer(file); + }; + + // Submit bulk import + const handleSubmitImport = async () => { + if (parsedSubscribers.length === 0) { + setUploadError('No valid subscribers found in the file. Please check your data.'); + return; + } + + setIsUploading(true); + setUploadError(null); + + try { + // Process subscribers in batches to avoid payload size limits + const batchSize = 50; + const batches = []; + + for (let i = 0; i < parsedSubscribers.length; i += batchSize) { + batches.push(parsedSubscribers.slice(i, i + batchSize)); + } + + let allImported = []; + let allErrors = []; + + // Process batches sequentially + for (let i = 0; i < batches.length; i++) { + const batch = batches[i]; + setUploadProgress(Math.round((i / batches.length) * 100)); + + try { + const response = await importSubscribers.mutateAsync({listId, subscribers: batch}); + if (response.imported) allImported = [...allImported, ...response.imported]; + if (response.errors) allErrors = [...allErrors, ...response.errors]; + + } catch (error) { + console.error(`Error processing batch ${i+1}:`, error); + // Add error for each subscriber in the failed batch + const batchErrors = batch.map(subscriber => ({ + email: subscriber.email, + reason: `Server error: ${error.message || 'Unknown error'}` + })); + allErrors = [...allErrors, ...batchErrors]; + } + } + + // Show results to user + setUploadResults({ + imported: allImported, + errors: allErrors + }); + + setUploadSuccess(true); + + // Refresh subscribers list + // This assumes your useSubscribers hook uses React Query under the hood + // and has refetch functionality + + } catch (error) { + setUploadError(`Failed to import subscribers: ${error.message}`); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + // Download template for subscriber import + const handleDownloadTemplate = () => { + // Create template headers + const headers = [ + 'Email', + 'First Name', + 'Last Name', + 'Status' + ]; + + // Create example data row + const exampleRow = [ + 'subscriber@example.com', + 'John', + 'Doe', + 'active', // Valid statuses: active, unsubscribed + ]; + + // Create workbook + const worksheet = XLSX.utils.aoa_to_sheet([headers, exampleRow]); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Subscribers Template'); + + // Add column widths for better readability + const colWidths = [ + { wch: 30 }, // Email + { wch: 15 }, // First Name + { wch: 15 }, // Last Name + { wch: 15 } // Status + ]; + worksheet['!cols'] = colWidths; + + // Generate Excel file + XLSX.writeFile(workbook, 'subscribers_import_template.xlsx'); + }; + // Loading state if (listLoading) { return ; @@ -285,14 +571,49 @@ const SubscribersPage = () => { {mailingList?.name} Subscribers - + + + + + + + + + + + + + + + {mailingList?.description && ( @@ -768,6 +1089,204 @@ const SubscribersPage = () => { + + {/* Import Subscribers Dialog */} + + Import Subscribers + + {!uploadSuccess ? ( + <> + + Upload a CSV or Excel file containing subscriber data. Make sure the file follows the required format with at least an "Email" column. + + + + {/* Drag and drop area */} + uploadFile ? theme.palette.success.main : theme.palette.divider, + borderRadius: 1, + p: 3, + mb: 2, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme => uploadFile ? 'rgba(76, 175, 80, 0.08)' : 'transparent', + cursor: isUploading ? 'not-allowed' : 'pointer', + transition: 'all 0.2s ease', + '&:hover': { + borderColor: theme => !isUploading && theme.palette.primary.main, + backgroundColor: theme => !isUploading && 'rgba(25, 118, 210, 0.04)' + } + }} + component="label" + onDrop={e => { + e.preventDefault(); + if (isUploading) return; + + const files = e.dataTransfer.files; + if (files.length) { + // Update the file input to reflect the dragged file + if (fileInputRef.current) { + // Create a DataTransfer object + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(files[0]); + fileInputRef.current.files = dataTransfer.files; + + // Trigger the change event handler + const event = new Event('change', { bubbles: true }); + fileInputRef.current.dispatchEvent(event); + } + } + }} + onDragOver={e => { + e.preventDefault(); + if (!isUploading) { + e.currentTarget.style.borderColor = '#1976d2'; + e.currentTarget.style.backgroundColor = 'rgba(25, 118, 210, 0.04)'; + } + }} + onDragLeave={e => { + e.preventDefault(); + if (!uploadFile) { + e.currentTarget.style.borderColor = ''; + e.currentTarget.style.backgroundColor = ''; + } + }} + > + + + {uploadFile ? ( + <> + + + File selected: {uploadFile.name} + + + Drag and drop a different file or click to change + + + ) : ( + <> + + + Drag and drop your CSV or Excel file here + + + or click to select a file + + + )} + + + {parsedSubscribers.length > 0 && ( + + Found {parsedSubscribers.length} valid subscribers in the file. + + )} + + {uploadError && ( + + {uploadError} + + )} + + {/* Show progress bar when uploading multiple batches */} + {isUploading && ( + + + Uploading subscribers: {uploadProgress}% + + + + )} + + + ) : ( + + + Import successful! + + + {uploadResults && ( + <> + + Import Results: + + + + {uploadResults.imported?.length || 0} subscribers imported successfully + + + {uploadResults.errors?.length > 0 && ( + <> + + {uploadResults.errors.length} subscribers failed to import + + + + {uploadResults.errors.map((err, index) => ( + + Error: {err.email} - {err.reason} + + ))} + + + )} + + )} + + )} + + + {!uploadSuccess ? ( + <> + + + + ) : ( + + )} + + ); };