mulk email import n export
This commit is contained in:
parent
7bd4f3de0e
commit
2bdcbb17d9
7 changed files with 676 additions and 259 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue