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",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
errorCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if subscriber already exists
|
for (let i = 0; i < subscribers.length; i += batchSize) {
|
||||||
let subscriberId;
|
const batch = subscribers.slice(i, i + batchSize);
|
||||||
const subscriberCheck = await client.query(
|
|
||||||
'SELECT id FROM subscribers WHERE email = $1',
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (subscriberCheck.rows.length > 0) {
|
// Process each subscriber in the batch
|
||||||
// Update existing subscriber
|
for (const data of batch) {
|
||||||
subscriberId = subscriberCheck.rows[0].id;
|
try {
|
||||||
await client.query(
|
// Validate email
|
||||||
`UPDATE subscribers
|
const email = data.email;
|
||||||
SET first_name = COALESCE($1, first_name),
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
last_name = COALESCE($2, last_name),
|
errorCount++;
|
||||||
status = COALESCE($3, status),
|
errors.push({
|
||||||
updated_at = NOW()
|
email: email || 'Invalid email',
|
||||||
WHERE id = $4`,
|
reason: 'Invalid email format'
|
||||||
[data.first_name, data.last_name, data.status || 'active', subscriberId]
|
});
|
||||||
|
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
|
if (subscriberCheck.rows.length > 0) {
|
||||||
const listSubscriberCheck = await client.query(
|
// Update existing subscriber
|
||||||
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2',
|
subscriberId = subscriberCheck.rows[0].id;
|
||||||
[listId, subscriberId]
|
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']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (listSubscriberCheck.rows.length === 0) {
|
// Check if subscriber is already on this list
|
||||||
// Add subscriber to list
|
const listSubscriberCheck = await client.query(
|
||||||
await client.query(
|
'SELECT * FROM mailing_list_subscribers WHERE list_id = $1 AND subscriber_id = $2',
|
||||||
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
|
|
||||||
VALUES ($1, $2)`,
|
|
||||||
[listId, subscriberId]
|
[listId, subscriberId]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
importedCount++;
|
if (listSubscriberCheck.rows.length === 0) {
|
||||||
} catch (err) {
|
// Add subscriber to list
|
||||||
console.error('Error importing subscriber:', err);
|
await client.query(
|
||||||
errorCount++;
|
`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'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
{ wch: 25 } // Last Activity
|
||||||
|
];
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
|
||||||
if (err) {
|
// Add worksheet to workbook
|
||||||
console.error('Error sending file:', err);
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Subscribers');
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({
|
// Generate timestamp for filename
|
||||||
error: true,
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
message: 'Error sending file'
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue