mulk email import n export

This commit is contained in:
2ManyProjects 2025-05-06 12:19:59 -05:00
parent 7bd4f3de0e
commit 2bdcbb17d9
7 changed files with 676 additions and 259 deletions

View file

@ -26,7 +26,8 @@
"pg-hstore": "^2.3.4", "pg-hstore": "^2.3.4",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"stripe": "^12.0.0", "stripe": "^12.0.0",
"uuid": "^9.0.0" "uuid": "^9.0.0",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.22" "nodemon": "^2.0.22"

View file

@ -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 * POST /api/admin/mailing-lists/import
*/ */
router.post('/import', upload.single('file'), async (req, res, next) => { router.post('/import', async (req, res, next) => {
try { try {
const { listId } = req.body; const { listId, subscribers } = req.body;
const file = req.file;
if (!req.user.is_admin) { if (!req.user.is_admin) {
return res.status(403).json({ 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({ return res.status(400).json({
error: true, 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 // Begin transaction
const client = await pool.connect(); const client = await pool.connect();
let importedCount = 0; let importedCount = 0;
let errorCount = 0; let errorCount = 0;
const errors = [];
const imported = [];
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
for (const data of subscribers) { // Process subscribers in batches to avoid overwhelming the database
try { const batchSize = 50;
// Validate email
const email = data.email; for (let i = 0; i < subscribers.length; i += batchSize) {
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { const batch = subscribers.slice(i, i + batchSize);
errorCount++;
continue; // Process each subscriber in the batch
} for (const data of batch) {
try {
// Check if subscriber already exists // Validate email
let subscriberId; const email = data.email;
const subscriberCheck = await client.query( if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
'SELECT id FROM subscribers WHERE email = $1', errorCount++;
[email] errors.push({
); email: email || 'Invalid email',
reason: 'Invalid email format'
if (subscriberCheck.rows.length > 0) { });
// Update existing subscriber continue;
subscriberId = subscriberCheck.rows[0].id; }
await client.query(
`UPDATE subscribers // Check if subscriber already exists
SET first_name = COALESCE($1, first_name), let subscriberId;
last_name = COALESCE($2, last_name), const subscriberCheck = await client.query(
status = COALESCE($3, status), 'SELECT id FROM subscribers WHERE email = $1',
updated_at = NOW() [email]
WHERE id = $4`,
[data.first_name, data.last_name, data.status || 'active', subscriberId]
); );
} else {
// Create new subscriber if (subscriberCheck.rows.length > 0) {
subscriberId = uuidv4(); // Update existing subscriber
await client.query( subscriberId = subscriberCheck.rows[0].id;
`INSERT INTO subscribers (id, email, first_name, last_name, status) await client.query(
VALUES ($1, $2, $3, $4, $5)`, `UPDATE subscribers
[subscriberId, email, data.first_name || null, data.last_name || null, data.status || 'active'] SET first_name = COALESCE($1, first_name),
); last_name = COALESCE($2, last_name),
} status = COALESCE($3, status),
updated_at = NOW()
// Check if subscriber is already on this list WHERE id = $4`,
const listSubscriberCheck = await client.query( [data.first_name, data.last_name, data.status || 'active', subscriberId]
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2', );
[listId, subscriberId] } else {
); // Create new subscriber
subscriberId = uuidv4();
if (listSubscriberCheck.rows.length === 0) { await client.query(
// Add subscriber to list `INSERT INTO subscribers (id, email, first_name, last_name, status)
await client.query( VALUES ($1, $2, $3, $4, $5)`,
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id) [subscriberId, email, data.first_name || null, data.last_name || null, data.status || 'active']
VALUES ($1, $2)`, );
}
// 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] [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`, message: `Import completed with ${importedCount} subscribers added and ${errorCount} errors`,
importedCount, importedCount,
errorCount, errorCount,
imported,
errors,
listId listId
}); });
} catch (error) { } catch (error) {
@ -653,20 +621,14 @@ module.exports = (pool, query, authMiddleware) => {
client.release(); client.release();
} }
} catch (error) { } 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); 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 * GET /api/admin/mailing-lists/:id/export
*/ */
router.get('/:id/export', async (req, res, next) => { router.get('/:id/export', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
@ -711,55 +673,48 @@ module.exports = (pool, query, authMiddleware) => {
const subscribersResult = await query(subscribersQuery, [id]); const subscribersResult = await query(subscribersQuery, [id]);
const subscribers = subscribersResult.rows; const subscribers = subscribersResult.rows;
// Create a temp file for CSV // Create Excel workbook and worksheet
const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const XLSX = require('xlsx');
const fileName = `subscribers-${listName.replace(/[^a-z0-9]/gi, '-').toLowerCase()}-${timestamp}.csv`; const workbook = XLSX.utils.book_new();
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 CSV writer // Format subscribers data for Excel
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
const formattedSubscribers = subscribers.map(sub => ({ const formattedSubscribers = subscribers.map(sub => ({
...sub, Email: sub.email,
subscribed_at: sub.subscribed_at ? new Date(sub.subscribed_at).toISOString() : '', 'First Name': sub.first_name || '',
last_activity_at: sub.last_activity_at ? new Date(sub.last_activity_at).toISOString() : '' '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 // Create worksheet
await csvWriter.writeRecords(formattedSubscribers); const worksheet = XLSX.utils.json_to_sheet(formattedSubscribers);
// Send file and delete after sending // Set column widths for better readability
res.download(filePath, fileName, (err) => { const colWidths = [
// Delete the temp file after sending { wch: 30 }, // Email
fs.unlink(filePath, (unlinkErr) => { { wch: 15 }, // First Name
if (unlinkErr) console.error('Error deleting temp file:', unlinkErr); { wch: 15 }, // Last Name
}); { wch: 10 }, // Status
{ wch: 25 }, // Subscribed At
if (err) { { wch: 25 } // Last Activity
console.error('Error sending file:', err); ];
if (!res.headersSent) { worksheet['!cols'] = colWidths;
res.status(500).json({
error: true, // Add worksheet to workbook
message: 'Error sending file' 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) { } catch (error) {
next(error); next(error);
} }

View file

@ -68,7 +68,7 @@ const CookieConsentPopup = () => {
}; };
// Reopen the consent popup (for testing or if user wants to change settings) // 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 = () => { const reopenConsentPopup = () => {
setOpen(true); setOpen(true);
}; };

View file

@ -424,11 +424,7 @@ export const useImportSubscribers = () => {
return useMutation({ return useMutation({
mutationFn: async (formData) => { mutationFn: async (formData) => {
const response = await apiClient.post('/admin/mailing-lists/import', formData, { const response = await apiClient.post('/admin/mailing-lists/import', formData);
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data; return response.data;
}, },
onSuccess: (data) => { onSuccess: (data) => {
@ -460,12 +456,24 @@ export const useExportSubscribers = () => {
responseType: 'blob' 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 // 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 url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `subscribers-${listId}.csv`; a.download = filename;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);

View file

@ -1,12 +1,10 @@
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useMemo } from 'react'; import { useMemo } from 'react';
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = () => useDispatch(); export const useAppDispatch = () => useDispatch();
export const useAppSelector = useSelector; export const useAppSelector = useSelector;
// Create a custom hook for notifications
export const useNotification = () => { export const useNotification = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();

View file

@ -46,20 +46,16 @@ import {
useMailingLists, useMailingLists,
useCreateMailingList, useCreateMailingList,
useUpdateMailingList, useUpdateMailingList,
useDeleteMailingList, useDeleteMailingList
useImportSubscribers, } from '@hooks/emailCampaignHooks';
useExportSubscribers
} from '../../hooks/emailCampaignHooks';
const MailingListsPage = () => { const MailingListsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [newListDialogOpen, setNewListDialogOpen] = useState(false); const [newListDialogOpen, setNewListDialogOpen] = useState(false);
const [editListData, setEditListData] = useState(null); const [editListData, setEditListData] = useState(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [selectedListId, setSelectedListId] = useState(null); const [selectedListId, setSelectedListId] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
const [anchorEl, setAnchorEl] = useState(null); const [anchorEl, setAnchorEl] = useState(null);
// Fetch all mailing lists // Fetch all mailing lists
@ -69,8 +65,6 @@ const MailingListsPage = () => {
const createList = useCreateMailingList(); const createList = useCreateMailingList();
const updateList = useUpdateMailingList(); const updateList = useUpdateMailingList();
const deleteList = useDeleteMailingList(); const deleteList = useDeleteMailingList();
const importSubscribers = useImportSubscribers();
const exportSubscribers = useExportSubscribers();
// New list form state // New list form state
const [newListForm, setNewListForm] = useState({ 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 // Loading state
if (isLoading) { if (isLoading) {
@ -300,18 +260,6 @@ const MailingListsPage = () => {
<ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon> <ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon>
<ListItemText>Delete</ListItemText> <ListItemText>Delete</ListItemText>
</MenuItem> </MenuItem>
<MenuItem onClick={() => {
setImportDialogOpen(true);
setSelectedListId(list.id);
handleMenuClose();
}}>
<ListItemIcon><UploadIcon fontSize="small" /></ListItemIcon>
<ListItemText>Import Subscribers</ListItemText>
</MenuItem>
<MenuItem onClick={handleExport}>
<ListItemIcon><DownloadIcon fontSize="small" /></ListItemIcon>
<ListItemText>Export Subscribers</ListItemText>
</MenuItem>
<MenuItem onClick={() => navigate(`/admin/mailing-lists/${list.id}/subscribers`)}> <MenuItem onClick={() => navigate(`/admin/mailing-lists/${list.id}/subscribers`)}>
<ListItemIcon><PeopleIcon fontSize="small" /></ListItemIcon> <ListItemIcon><PeopleIcon fontSize="small" /></ListItemIcon>
<ListItemText>View Subscribers</ListItemText> <ListItemText>View Subscribers</ListItemText>
@ -393,18 +341,6 @@ const MailingListsPage = () => {
<Button variant="contained" color="error" onClick={handleConfirmDelete}>Delete</Button> <Button variant="contained" color="error" onClick={handleConfirmDelete}>Delete</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Import Subscribers Dialog */}
<Dialog open={importDialogOpen} onClose={() => setImportDialogOpen(false)}>
<DialogTitle>Import Subscribers</DialogTitle>
<DialogContent>
<input type="file" accept=".csv" onChange={handleFileChange} />
</DialogContent>
<DialogActions>
<Button onClick={() => setImportDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleImport}>Import</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
}; };

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { import {
Box, Box,
Typography, Typography,
@ -15,6 +15,7 @@ import {
Chip, Chip,
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContentText,
DialogContent, DialogContent,
DialogActions, DialogActions,
TextField, TextField,
@ -31,7 +32,9 @@ import {
InputLabel, InputLabel,
Select, Select,
Breadcrumbs, Breadcrumbs,
Link Link,
Stack,
LinearProgress
} from '@mui/material'; } from '@mui/material';
import { import {
Add as AddIcon, Add as AddIcon,
@ -43,21 +46,31 @@ import {
Search as SearchIcon, Search as SearchIcon,
Clear as ClearIcon, Clear as ClearIcon,
Block as BlockIcon, Block as BlockIcon,
CheckCircle as CheckCircleIcon CheckCircle as CheckCircleIcon,
FileDownload as DownloadIcon,
Upload as UploadIcon,
CloudUpload,
CheckCircleOutline
} from '@mui/icons-material'; } from '@mui/icons-material';
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom'; import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as XLSX from 'xlsx';
import { import {
useMailingList, useMailingList,
useSubscribers, useSubscribers,
useAddSubscriber, useAddSubscriber,
useUpdateSubscriber, useUpdateSubscriber,
useDeleteSubscriber, useDeleteSubscriber,
useSubscriberActivity useSubscriberActivity,
useExportSubscribers,
useImportSubscribers
} from '@hooks/emailCampaignHooks'; } from '@hooks/emailCampaignHooks';
import apiClient from '@services/api';
import { useNotification } from '@hooks/reduxHooks';
const SubscribersPage = () => { const SubscribersPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const notification = useNotification();
const { listId } = useParams(); const { listId } = useParams();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@ -69,6 +82,18 @@ const SubscribersPage = () => {
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activityDialogOpen, setActivityDialogOpen] = 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 // New subscriber form data
const [subscriberForm, setSubscriberForm] = useState({ const [subscriberForm, setSubscriberForm] = useState({
email: '', email: '',
@ -91,6 +116,8 @@ const SubscribersPage = () => {
// Mutations // Mutations
const addSubscriber = useAddSubscriber(); const addSubscriber = useAddSubscriber();
const updateSubscriber = useUpdateSubscriber(); const updateSubscriber = useUpdateSubscriber();
const importSubscribers = useImportSubscribers();
const exportSubscribers = useExportSubscribers();
const deleteSubscriber = useDeleteSubscriber(); const deleteSubscriber = useDeleteSubscriber();
// Handle form input changes // 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 // Loading state
if (listLoading) { if (listLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
@ -285,14 +571,49 @@ const SubscribersPage = () => {
<Typography variant="h4" component="h1"> <Typography variant="h4" component="h1">
{mailingList?.name} Subscribers {mailingList?.name} Subscribers
</Typography> </Typography>
<Button <Stack direction="row" spacing={2}>
variant="contained" <Tooltip title="Download subscriber import template">
color="primary" <Button
startIcon={<AddIcon />} variant="outlined"
onClick={() => setAddDialogOpen(true)} color="primary"
> startIcon={<DownloadIcon />}
Add Subscriber onClick={handleDownloadTemplate}
</Button> >
Download Template
</Button>
</Tooltip>
<Tooltip title="Export all subscribers to Excel">
<Button
variant="outlined"
color="primary"
startIcon={<DownloadIcon />}
onClick={handleExportSubscribers}
>
Export Subscribers
</Button>
</Tooltip>
<Tooltip title="Import subscribers from Excel or CSV">
<Button
variant="outlined"
color="primary"
startIcon={<UploadIcon />}
onClick={handleOpenImportDialog}
>
Import Subscribers
</Button>
</Tooltip>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => setAddDialogOpen(true)}
>
Add Subscriber
</Button>
</Stack>
</Box> </Box>
{mailingList?.description && ( {mailingList?.description && (
<Typography variant="body1" color="text.secondary" mt={1}> <Typography variant="body1" color="text.secondary" mt={1}>
@ -768,6 +1089,204 @@ const SubscribersPage = () => {
<Button onClick={() => setActivityDialogOpen(false)}>Close</Button> <Button onClick={() => setActivityDialogOpen(false)}>Close</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Import Subscribers Dialog */}
<Dialog
open={importDialogOpen}
onClose={handleCloseImportDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>Import Subscribers</DialogTitle>
<DialogContent>
{!uploadSuccess ? (
<>
<DialogContentText sx={{ mb: 2 }}>
Upload a CSV or Excel file containing subscriber data. Make sure the file follows the required format with at least an "Email" column.
</DialogContentText>
<Box
sx={{
mb: 3,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
{/* Drag and drop area */}
<Box
sx={{
width: '100%',
border: '2px dashed',
borderColor: theme => 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 = '';
}
}}
>
<input
ref={fileInputRef}
type="file"
accept=".csv, .xlsx, .xls"
hidden
onChange={handleFileSelect}
disabled={isUploading}
/>
{uploadFile ? (
<>
<CheckCircleOutline sx={{ color: 'success.main', fontSize: 48, mb: 1 }} />
<Typography variant="body1" align="center" gutterBottom>
File selected: {uploadFile.name}
</Typography>
<Typography variant="body2" color="text.secondary" align="center">
Drag and drop a different file or click to change
</Typography>
</>
) : (
<>
<CloudUpload sx={{ fontSize: 48, color: 'action.active', mb: 1 }} />
<Typography variant="body1" align="center" gutterBottom>
Drag and drop your CSV or Excel file here
</Typography>
<Typography variant="body2" color="text.secondary" align="center">
or click to select a file
</Typography>
</>
)}
</Box>
{parsedSubscribers.length > 0 && (
<Alert severity="info" sx={{ mt: 2, width: '100%' }}>
Found {parsedSubscribers.length} valid subscribers in the file.
</Alert>
)}
{uploadError && (
<Alert severity="error" sx={{ mt: 2, width: '100%' }}>
{uploadError}
</Alert>
)}
{/* Show progress bar when uploading multiple batches */}
{isUploading && (
<Box sx={{ width: '100%', mt: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Uploading subscribers: {uploadProgress}%
</Typography>
<LinearProgress
variant="determinate"
value={uploadProgress}
sx={{ height: 10, borderRadius: 1 }}
/>
</Box>
)}
</Box>
</>
) : (
<Box sx={{ mt: 2, mb: 2 }}>
<Alert severity="success" sx={{ mb: 2 }}>
Import successful!
</Alert>
{uploadResults && (
<>
<Typography variant="subtitle1" sx={{ mt: 2, mb: 1 }}>
Import Results:
</Typography>
<Typography variant="body2">
{uploadResults.imported?.length || 0} subscribers imported successfully
</Typography>
{uploadResults.errors?.length > 0 && (
<>
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
{uploadResults.errors.length} subscribers failed to import
</Typography>
<Paper variant="outlined" sx={{ mt: 1, p: 1, maxHeight: 150, overflow: 'auto' }}>
{uploadResults.errors.map((err, index) => (
<Typography key={index} variant="caption" display="block" color="error">
Error: {err.email} - {err.reason}
</Typography>
))}
</Paper>
</>
)}
</>
)}
</Box>
)}
</DialogContent>
<DialogActions>
{!uploadSuccess ? (
<>
<Button onClick={handleCloseImportDialog} color="primary">
Cancel
</Button>
<Button
onClick={handleSubmitImport}
color="primary"
variant="contained"
disabled={isUploading || parsedSubscribers.length === 0}
startIcon={isUploading && <CircularProgress size={20} color="inherit" />}
>
{isUploading ? 'Uploading...' : 'Import Subscribers'}
</Button>
</>
) : (
<Button onClick={handleCloseImportDialog} color="primary">
Close
</Button>
)}
</DialogActions>
</Dialog>
</Box> </Box>
); );
}; };