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",
"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"

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
*/
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);
}

View file

@ -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);
};

View file

@ -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);

View file

@ -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();

View file

@ -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 = () => {
<ListItemIcon><DeleteIcon fontSize="small" /></ListItemIcon>
<ListItemText>Delete</ListItemText>
</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`)}>
<ListItemIcon><PeopleIcon fontSize="small" /></ListItemIcon>
<ListItemText>View Subscribers</ListItemText>
@ -393,18 +341,6 @@ const MailingListsPage = () => {
<Button variant="contained" color="error" onClick={handleConfirmDelete}>Delete</Button>
</DialogActions>
</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>
);
};

View file

@ -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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
@ -285,14 +571,49 @@ const SubscribersPage = () => {
<Typography variant="h4" component="h1">
{mailingList?.name} Subscribers
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => setAddDialogOpen(true)}
>
Add Subscriber
</Button>
<Stack direction="row" spacing={2}>
<Tooltip title="Download subscriber import template">
<Button
variant="outlined"
color="primary"
startIcon={<DownloadIcon />}
onClick={handleDownloadTemplate}
>
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>
{mailingList?.description && (
<Typography variant="body1" color="text.secondary" mt={1}>
@ -768,6 +1089,204 @@ const SubscribersPage = () => {
<Button onClick={() => setActivityDialogOpen(false)}>Close</Button>
</DialogActions>
</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>
);
};