Mailing list implemntation
This commit is contained in:
parent
3960853e61
commit
7de980ba1b
27 changed files with 7980 additions and 30 deletions
10
backend/package-lock.json
generated
10
backend/package-lock.json
generated
|
|
@ -70,6 +70,16 @@
|
|||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"csv-parser": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
|
||||
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA=="
|
||||
},
|
||||
"csv-writer": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz",
|
||||
"integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g=="
|
||||
},
|
||||
"delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parser": "^3.2.0",
|
||||
"csv-writer": "^1.6.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"morgan": "^1.10.0",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ const productReviewsRoutes = require('./routes/productReviews');
|
|||
const productReviewsAdminRoutes = require('./routes/productReviewsAdmin');
|
||||
const emailTemplatesAdminRoutes = require('./routes/emailTemplatesAdmin');
|
||||
const publicSettingsRoutes = require('./routes/publicSettings');
|
||||
const mailingListRoutes = require('./routes/mailingListAdmin');
|
||||
const emailCampaignListRoutes = require('./routes/emailCampaignsAdmin');
|
||||
const subscribersAdminRoutes = require('./routes/subscribersAdmin');
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
|
@ -220,11 +223,20 @@ app.use('/api/admin/product-reviews', productReviewsAdminRoutes(pool, query, adm
|
|||
|
||||
app.use('/api/admin/users', usersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/coupons', couponsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/subscribers', subscribersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
|
||||
app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/blog', blogAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/blog-comments', blogCommentsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/email-templates', emailTemplatesAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
|
||||
|
||||
app.use('/api/admin/mailing-lists', mailingListRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/email-campaigns', emailCampaignListRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
|
||||
|
||||
|
||||
|
||||
// Admin-only product image upload
|
||||
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
||||
console.log('/api/image/product', req.file);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const router = express.Router();
|
|||
module.exports = (pool, query) => {
|
||||
// Register new user
|
||||
router.post('/register', async (req, res, next) => {
|
||||
const { email, firstName, lastName } = req.body;
|
||||
const { email, firstName, lastName, isSubscribed = false } = req.body;
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
|
|
@ -29,7 +29,19 @@ module.exports = (pool, query) => {
|
|||
'INSERT INTO users (email, first_name, last_name) VALUES ($1, $2, $3) RETURNING id, email',
|
||||
[email, firstName, lastName]
|
||||
);
|
||||
|
||||
console.log("REGISTERED NEW USER ", email)
|
||||
if(isSubscribed){
|
||||
let subResult = await query(
|
||||
`INSERT INTO subscribers (id, email, first_name, last_name, status)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id, email`,
|
||||
[result.rows[0].id, email, firstName, lastName, 'active']
|
||||
);
|
||||
let mailResult = await query(
|
||||
`INSERT INTO mailing_list_subscribers (list_id, subscriber_id)
|
||||
VALUES ($1, $2)`,
|
||||
['1db91b9b-b1f9-4892-80b5-51437d8b6045', result.rows[0].id]
|
||||
);
|
||||
}
|
||||
res.status(201).json({
|
||||
message: 'User registered successfully',
|
||||
user: result.rows[0]
|
||||
|
|
|
|||
1030
backend/src/routes/emailCampaignsAdmin.js
Normal file
1030
backend/src/routes/emailCampaignsAdmin.js
Normal file
File diff suppressed because it is too large
Load diff
734
backend/src/routes/mailingListAdmin.js
Normal file
734
backend/src/routes/mailingListAdmin.js
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const csv = require('csv-parser');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createObjectCsvWriter } = require('csv-writer');
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({
|
||||
dest: path.join(__dirname, '../uploads/temp'),
|
||||
limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit
|
||||
});
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
// Apply authentication middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* Get all mailing lists
|
||||
* GET /api/admin/mailing-lists
|
||||
*/
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all mailing lists with subscriber counts
|
||||
const result = await query(`
|
||||
SELECT
|
||||
ml.id,
|
||||
ml.name,
|
||||
ml.description,
|
||||
ml.created_at,
|
||||
ml.updated_at,
|
||||
COUNT(ms.subscriber_id) AS subscriber_count
|
||||
FROM mailing_lists ml
|
||||
LEFT JOIN mailing_list_subscribers ms ON ml.id = ms.list_id
|
||||
GROUP BY
|
||||
ml.id, ml.name, ml.description, ml.created_at, ml.updated_at
|
||||
ORDER BY ml.name;
|
||||
`);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single mailing list by ID
|
||||
* GET /api/admin/mailing-lists/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get mailing list with subscriber count
|
||||
const result = await query(`
|
||||
SELECT
|
||||
ml.id,
|
||||
ml.name,
|
||||
ml.description,
|
||||
ml.created_at,
|
||||
ml.updated_at,
|
||||
COUNT(ms.subscriber_id) AS subscriber_count
|
||||
FROM mailing_lists ml
|
||||
LEFT JOIN mailing_list_subscribers ms ON ml.id = ms.list_id
|
||||
WHERE ml.id = $1
|
||||
GROUP BY
|
||||
ml.id, ml.name, ml.description, ml.created_at, ml.updated_at;
|
||||
`, [id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new mailing list
|
||||
* POST /api/admin/mailing-lists
|
||||
*/
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'List name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Create new mailing list
|
||||
const listId = uuidv4();
|
||||
const result = await query(
|
||||
`INSERT INTO mailing_lists (id, name, description)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[listId, name, description]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update a mailing list
|
||||
* PUT /api/admin/mailing-lists/:id
|
||||
*/
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!name) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'List name is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT id FROM mailing_lists WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Update mailing list
|
||||
const result = await query(
|
||||
`UPDATE mailing_lists
|
||||
SET name = $1, description = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING *`,
|
||||
[name, description, id]
|
||||
);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a mailing list
|
||||
* DELETE /api/admin/mailing-lists/:id
|
||||
*/
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT id FROM mailing_lists WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Delete all subscribers from this list
|
||||
await client.query(
|
||||
'DELETE FROM mailing_list_subscribers WHERE list_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Delete the mailing list
|
||||
await client.query(
|
||||
'DELETE FROM mailing_lists WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Mailing list deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get subscribers for a mailing list
|
||||
* GET /api/admin/mailing-lists/:id/subscribers
|
||||
*/
|
||||
router.get('/:id/subscribers', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { page = 0, pageSize = 25, search = '', status } = req.query;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT id FROM mailing_lists WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare query conditions
|
||||
const conditions = ['ms.list_id = $1'];
|
||||
const queryParams = [id];
|
||||
let paramIndex = 2;
|
||||
|
||||
if (search) {
|
||||
conditions.push(`(
|
||||
s.email ILIKE $${paramIndex} OR
|
||||
s.first_name ILIKE $${paramIndex} OR
|
||||
s.last_name ILIKE $${paramIndex}
|
||||
)`);
|
||||
queryParams.push(`%${search}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
conditions.push(`s.status = $${paramIndex}`);
|
||||
queryParams.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Build WHERE clause
|
||||
const whereClause = conditions.length > 0
|
||||
? `WHERE ${conditions.join(' AND ')}`
|
||||
: '';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM mailing_list_subscribers ms
|
||||
JOIN subscribers s ON ms.subscriber_id = s.id
|
||||
${whereClause}
|
||||
`;
|
||||
|
||||
const countResult = await query(countQuery, queryParams);
|
||||
const totalCount = parseInt(countResult.rows[0].total, 10);
|
||||
|
||||
// Get filtered count if status filter is applied
|
||||
let filteredCount;
|
||||
if (status && status !== 'all') {
|
||||
const filteredCountQuery = `
|
||||
SELECT COUNT(*) as filtered
|
||||
FROM mailing_list_subscribers ms
|
||||
JOIN subscribers s ON ms.subscriber_id = s.id
|
||||
WHERE ms.list_id = $1 AND s.status = $2
|
||||
`;
|
||||
const filteredCountResult = await query(filteredCountQuery, [id, status]);
|
||||
filteredCount = parseInt(filteredCountResult.rows[0].filtered, 10);
|
||||
}
|
||||
|
||||
// Get subscribers with pagination
|
||||
const offset = page * pageSize;
|
||||
const subscribersQuery = `
|
||||
SELECT
|
||||
s.id,
|
||||
s.email,
|
||||
s.first_name,
|
||||
s.last_name,
|
||||
s.status,
|
||||
ms.subscribed_at,
|
||||
s.last_activity_at
|
||||
FROM mailing_list_subscribers ms
|
||||
JOIN subscribers s ON ms.subscriber_id = s.id
|
||||
${whereClause}
|
||||
ORDER BY s.email
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
queryParams.push(parseInt(pageSize, 10), offset);
|
||||
const subscribersResult = await query(subscribersQuery, queryParams);
|
||||
|
||||
res.json({
|
||||
subscribers: subscribersResult.rows,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
page: parseInt(page, 10),
|
||||
pageSize: parseInt(pageSize, 10)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Add a subscriber to a mailing list
|
||||
* POST /api/admin/mailing-lists/:id/subscribers
|
||||
*/
|
||||
router.post('/:id/subscribers', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { email, firstName, lastName, status = 'active' } = req.body;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Email is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT id FROM mailing_lists WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 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 = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[firstName, lastName, status, 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, firstName, lastName, status]
|
||||
);
|
||||
}
|
||||
|
||||
// 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',
|
||||
[id, 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)`,
|
||||
[id, subscriberId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Subscriber added to list',
|
||||
subscriberId,
|
||||
listId: id
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Import subscribers to a mailing list from CSV
|
||||
* POST /api/admin/mailing-lists/import
|
||||
*/
|
||||
router.post('/import', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
const { listId } = req.body;
|
||||
const file = req.file;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!listId) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'List ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'CSV file is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT id FROM mailing_lists WHERE id = $1',
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
|
||||
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]
|
||||
);
|
||||
} 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++;
|
||||
} catch (err) {
|
||||
console.error('Error importing subscriber:', err);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `Import completed with ${importedCount} subscribers added and ${errorCount} errors`,
|
||||
importedCount,
|
||||
errorCount,
|
||||
listId
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
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
|
||||
* GET /api/admin/mailing-lists/:id/export
|
||||
*/
|
||||
router.get('/:id/export', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT name FROM mailing_lists WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
const listName = listCheck.rows[0].name;
|
||||
|
||||
// Get all subscribers for this list
|
||||
const subscribersQuery = `
|
||||
SELECT
|
||||
s.email,
|
||||
s.first_name,
|
||||
s.last_name,
|
||||
s.status,
|
||||
ms.subscribed_at,
|
||||
s.last_activity_at
|
||||
FROM mailing_list_subscribers ms
|
||||
JOIN subscribers s ON ms.subscriber_id = s.id
|
||||
WHERE ms.list_id = $1
|
||||
ORDER BY s.email
|
||||
`;
|
||||
|
||||
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 filePath = path.join(__dirname, '../uploads/temp', fileName);
|
||||
|
||||
// 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
|
||||
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() : ''
|
||||
}));
|
||||
|
||||
// Write to CSV
|
||||
await csvWriter.writeRecords(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'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
579
backend/src/routes/subscribersAdmin.js
Normal file
579
backend/src/routes/subscribersAdmin.js
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const router = express.Router();
|
||||
const emailService = require('../services/emailService');
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
|
||||
/**
|
||||
* Public route to handle subscription
|
||||
* POST /api/subscribers/subscribe
|
||||
*/
|
||||
router.post('/subscribe', async (req, res, next) => {
|
||||
try {
|
||||
const { email, firstName, lastName, listId } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!email || !listId) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Email and list ID are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if list exists
|
||||
const listCheck = await query(
|
||||
'SELECT id FROM mailing_lists WHERE id = $1',
|
||||
[listId]
|
||||
);
|
||||
|
||||
if (listCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Mailing list not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Check if subscriber already exists
|
||||
let subscriberId;
|
||||
const subscriberCheck = await client.query(
|
||||
'SELECT id, status FROM subscribers WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (subscriberCheck.rows.length > 0) {
|
||||
// Update existing subscriber
|
||||
subscriberId = subscriberCheck.rows[0].id;
|
||||
const currentStatus = subscriberCheck.rows[0].status;
|
||||
|
||||
// Only update if not already unsubscribed or complained
|
||||
if (currentStatus !== 'unsubscribed' && currentStatus !== 'complained') {
|
||||
await client.query(
|
||||
`UPDATE subscribers
|
||||
SET first_name = COALESCE($1, first_name),
|
||||
last_name = COALESCE($2, last_name),
|
||||
status = 'active',
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`,
|
||||
[firstName, lastName, subscriberId]
|
||||
);
|
||||
} else {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'This email has previously unsubscribed and cannot be resubscribed without consent'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Create new subscriber
|
||||
subscriberId = uuidv4();
|
||||
await client.query(
|
||||
`INSERT INTO subscribers (id, email, first_name, last_name)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[subscriberId, email, firstName, lastName]
|
||||
);
|
||||
}
|
||||
|
||||
// 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]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Generate a confirmation token for double opt-in
|
||||
const confirmationToken = uuidv4();
|
||||
|
||||
// Store the confirmation token
|
||||
await query(
|
||||
`INSERT INTO subscription_confirmations (subscriber_id, token, expires_at)
|
||||
VALUES ($1, $2, NOW() + INTERVAL '7 days')`,
|
||||
[subscriberId, confirmationToken]
|
||||
);
|
||||
|
||||
// Send confirmation email
|
||||
try {
|
||||
await emailService.sendSubscriptionConfirmation({
|
||||
to: email,
|
||||
firstName: firstName || '',
|
||||
confirmationLink: `${process.env.SITE_URL}/confirm-subscription?token=${confirmationToken}`
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send confirmation email:', emailError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Subscription request received. Please check your email to confirm your subscription.'
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Confirm subscription (double opt-in)
|
||||
* GET /api/subscribers/confirm
|
||||
*/
|
||||
router.get('/confirm', async (req, res, next) => {
|
||||
try {
|
||||
const { token } = req.query;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Confirmation token is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if token exists and is valid
|
||||
const confirmationQuery = `
|
||||
SELECT
|
||||
sc.subscriber_id,
|
||||
s.email,
|
||||
s.first_name
|
||||
FROM subscription_confirmations sc
|
||||
JOIN subscribers s ON sc.subscriber_id = s.id
|
||||
WHERE sc.token = $1 AND sc.expires_at > NOW() AND sc.confirmed_at IS NULL
|
||||
`;
|
||||
|
||||
const confirmationResult = await query(confirmationQuery, [token]);
|
||||
|
||||
if (confirmationResult.rows.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid or expired confirmation token'
|
||||
});
|
||||
}
|
||||
|
||||
const { subscriber_id, email, first_name } = confirmationResult.rows[0];
|
||||
|
||||
// Mark subscription as confirmed
|
||||
await query(
|
||||
`UPDATE subscription_confirmations
|
||||
SET confirmed_at = NOW()
|
||||
WHERE token = $1`,
|
||||
[token]
|
||||
);
|
||||
|
||||
// Ensure subscriber is marked as active
|
||||
await query(
|
||||
`UPDATE subscribers
|
||||
SET status = 'active', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[subscriber_id]
|
||||
);
|
||||
|
||||
// Send welcome email
|
||||
try {
|
||||
await emailService.sendWelcomeEmail({
|
||||
to: email,
|
||||
firstName: first_name || ''
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Your subscription has been confirmed. Thank you!'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle unsubscribe request
|
||||
* GET /api/subscribers/unsubscribe
|
||||
*/
|
||||
router.get('/unsubscribe', async (req, res, next) => {
|
||||
try {
|
||||
const { email, token, listId } = req.query;
|
||||
|
||||
if (!email && !token) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Email or token is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
let subscriberId;
|
||||
|
||||
if (token) {
|
||||
// Verify the unsubscribe token
|
||||
const tokenCheck = await client.query(
|
||||
'SELECT subscriber_id FROM unsubscribe_tokens WHERE token = $1 AND expires_at > NOW()',
|
||||
[token]
|
||||
);
|
||||
|
||||
if (tokenCheck.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid or expired unsubscribe token'
|
||||
});
|
||||
}
|
||||
|
||||
subscriberId = tokenCheck.rows[0].subscriber_id;
|
||||
} else {
|
||||
// Look up subscriber by email
|
||||
const subscriberCheck = await client.query(
|
||||
'SELECT id FROM subscribers WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (subscriberCheck.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Email not found in our database'
|
||||
});
|
||||
}
|
||||
|
||||
subscriberId = subscriberCheck.rows[0].id;
|
||||
}
|
||||
|
||||
// Update subscriber status
|
||||
await client.query(
|
||||
`UPDATE subscribers
|
||||
SET status = 'unsubscribed', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[subscriberId]
|
||||
);
|
||||
|
||||
// If list ID is provided, only remove from that list
|
||||
if (listId) {
|
||||
await client.query(
|
||||
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1 AND list_id = $2',
|
||||
[subscriberId, listId]
|
||||
);
|
||||
}
|
||||
// Otherwise remove from all lists
|
||||
else {
|
||||
await client.query(
|
||||
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
|
||||
[subscriberId]
|
||||
);
|
||||
}
|
||||
|
||||
// Record unsubscribe activity
|
||||
await client.query(
|
||||
`INSERT INTO subscriber_activity (subscriber_id, type, details)
|
||||
VALUES ($1, 'unsubscribe', $2)`,
|
||||
[subscriberId, listId ? `Unsubscribed from list ${listId}` : 'Unsubscribed from all lists']
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'You have been successfully unsubscribed'
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Public route to handle bounce and complaint notifications
|
||||
* POST /api/subscribers/webhook
|
||||
*/
|
||||
router.post('/webhook', async (req, res, next) => {
|
||||
try {
|
||||
const { type, email, message, campaignId, reason, timestamp } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!type || !email) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Type and email are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
// In production, implement proper verification of webhook authenticity
|
||||
|
||||
// Find subscriber by email
|
||||
const subscriberCheck = await query(
|
||||
'SELECT id FROM subscribers WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (subscriberCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Subscriber not found'
|
||||
});
|
||||
}
|
||||
|
||||
const subscriberId = subscriberCheck.rows[0].id;
|
||||
|
||||
// Handle event based on type
|
||||
switch (type) {
|
||||
case 'bounce':
|
||||
// Update subscriber status
|
||||
await query(
|
||||
`UPDATE subscribers
|
||||
SET status = 'bounced', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[subscriberId]
|
||||
);
|
||||
|
||||
// Record bounce activity
|
||||
await query(
|
||||
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details)
|
||||
VALUES ($1, $2, 'bounce', $3, $4)`,
|
||||
[subscriberId, campaignId, timestamp || new Date(), reason || message || 'Email bounced']
|
||||
);
|
||||
break;
|
||||
|
||||
case 'complaint':
|
||||
// Update subscriber status
|
||||
await query(
|
||||
`UPDATE subscribers
|
||||
SET status = 'complained', updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[subscriberId]
|
||||
);
|
||||
|
||||
// Remove from all lists
|
||||
await query(
|
||||
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
|
||||
[subscriberId]
|
||||
);
|
||||
|
||||
// Record complaint activity
|
||||
await query(
|
||||
`INSERT INTO subscriber_activity (subscriber_id, campaign_id, type, timestamp, details)
|
||||
VALUES ($1, $2, 'complaint', $3, $4)`,
|
||||
[subscriberId, campaignId, timestamp || new Date(), reason || message || 'Spam complaint received']
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Unsupported event type'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Processed ${type} for ${email}`
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* Update a subscriber (admin)
|
||||
* PUT /api/subscribers/:id
|
||||
*/
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { email, firstName, lastName, status } = req.body;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Email is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if subscriber exists
|
||||
const subscriberCheck = await query(
|
||||
'SELECT id FROM subscribers WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (subscriberCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Subscriber not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Update subscriber
|
||||
const result = await query(
|
||||
`UPDATE subscribers
|
||||
SET email = $1,
|
||||
first_name = $2,
|
||||
last_name = $3,
|
||||
status = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $5
|
||||
RETURNING *`,
|
||||
[email, firstName, lastName, status, id]
|
||||
);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a subscriber (admin)
|
||||
* DELETE /api/subscribers/:id
|
||||
*/
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if subscriber exists
|
||||
const subscriberCheck = await query(
|
||||
'SELECT id FROM subscribers WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (subscriberCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Subscriber not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Remove from all mailing lists
|
||||
await client.query(
|
||||
'DELETE FROM mailing_list_subscribers WHERE subscriber_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Delete subscriber
|
||||
await client.query(
|
||||
'DELETE FROM subscribers WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Subscriber deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get subscriber activity (admin)
|
||||
* GET /api/subscribers/:id/activity
|
||||
*/
|
||||
router.get('/:id/activity', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if subscriber exists
|
||||
const subscriberCheck = await query(
|
||||
'SELECT id FROM subscribers WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (subscriberCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Subscriber not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Get subscriber activity
|
||||
const activityQuery = `
|
||||
SELECT
|
||||
sa.id,
|
||||
sa.campaign_id,
|
||||
ec.name as campaign_name,
|
||||
sa.type,
|
||||
sa.timestamp,
|
||||
sa.details
|
||||
FROM subscriber_activity sa
|
||||
LEFT JOIN email_campaigns ec ON sa.campaign_id = ec.id
|
||||
WHERE sa.subscriber_id = $1
|
||||
ORDER BY sa.timestamp DESC
|
||||
`;
|
||||
|
||||
const activityResult = await query(activityQuery, [id]);
|
||||
|
||||
res.json(activityResult.rows);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
const { query, pool } = require('../db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
/**
|
||||
* Service for sending emails with templates
|
||||
|
|
@ -20,6 +21,8 @@ const emailService = {
|
|||
}
|
||||
});
|
||||
},
|
||||
defaultFrom: config.email.reply,
|
||||
siteUrl: `${config.site.protocol}://${config.site.apiDomain}`,
|
||||
|
||||
/**
|
||||
* Get a template by type, preferring the default one
|
||||
|
|
@ -138,6 +141,31 @@ const emailService = {
|
|||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Send an email
|
||||
* @param {Object} options - Email options for nodemailer
|
||||
* @returns {Promise} - Promise with the send result
|
||||
*/
|
||||
async sendEmail(options) {
|
||||
try {
|
||||
// Send email using nodemailer
|
||||
const transporter = this.createTransporter();
|
||||
const result = await transporter.sendMail({
|
||||
from: options.from || this.defaultFrom,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
attachments: options.attachments,
|
||||
headers: options.headers
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a login code email
|
||||
|
|
@ -204,6 +232,52 @@ const emailService = {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a test email for campaign preview
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.preheader - Email preheader
|
||||
* @param {string} options.from - From address
|
||||
* @param {string} options.content - HTML content
|
||||
* @returns {Promise} - Promise with the send result
|
||||
*/
|
||||
async sendTestEmail({ to, subject, preheader, from, content }) {
|
||||
try {
|
||||
// Add preheader if provided
|
||||
let htmlContent = content;
|
||||
if (preheader) {
|
||||
htmlContent = `
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
|
||||
${preheader}
|
||||
</div>
|
||||
${content}
|
||||
`;
|
||||
}
|
||||
|
||||
// Add test label at top
|
||||
htmlContent = `
|
||||
<div style="background-color:#ffeb3b;padding:10px;margin-bottom:10px;text-align:center;font-family:sans-serif;">
|
||||
<strong>TEST EMAIL</strong> - This is a test preview of your campaign.
|
||||
</div>
|
||||
${htmlContent}
|
||||
`;
|
||||
|
||||
// Send the test email
|
||||
return await this.sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html: htmlContent,
|
||||
from: from || this.defaultFrom,
|
||||
headers: {
|
||||
'X-Test-Email': 'true'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending test email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Send a low stock alert email
|
||||
* @param {Object} options - Options
|
||||
|
|
@ -238,6 +312,264 @@ const emailService = {
|
|||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Send a subscription confirmation email
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.firstName - Recipient's first name
|
||||
* @param {string} options.confirmationLink - Confirmation link
|
||||
* @returns {Promise} - Promise with the send result
|
||||
*/
|
||||
async sendSubscriptionConfirmation({ to, firstName, confirmationLink }) {
|
||||
try {
|
||||
// Get the email template
|
||||
const template = await this.getEmailTemplate('subscription_confirmation');
|
||||
|
||||
if (!template) {
|
||||
// Fallback to a basic template if none is found
|
||||
return await this.sendEmail({
|
||||
to,
|
||||
subject: 'Please confirm your subscription',
|
||||
html: `
|
||||
<h1>Confirm Your Subscription</h1>
|
||||
<p>Hello ${firstName || 'there'},</p>
|
||||
<p>Thank you for subscribing to our mailing list. Please click the link below to confirm your subscription:</p>
|
||||
<p><a href="${confirmationLink}">Confirm Subscription</a></p>
|
||||
<p>If you did not request this subscription, you can ignore this email.</p>
|
||||
`,
|
||||
from: this.defaultFrom
|
||||
});
|
||||
}
|
||||
|
||||
// Replace placeholders in the template
|
||||
let content = template.content;
|
||||
let subject = template.subject;
|
||||
|
||||
content = content
|
||||
.replace(/{{first_name}}/g, firstName || 'there')
|
||||
.replace(/{{confirmation_link}}/g, confirmationLink);
|
||||
|
||||
subject = subject
|
||||
.replace(/{{first_name}}/g, firstName || 'there');
|
||||
|
||||
// Send email
|
||||
return await this.sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html: content,
|
||||
from: template.from_name ? `${template.from_name} <${this.defaultFrom}>` : this.defaultFrom
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending subscription confirmation email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Send an email to a campaign subscriber
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.preheader - Email preheader
|
||||
* @param {string} options.from - From address
|
||||
* @param {string} options.content - HTML content
|
||||
* @param {string} options.campaignId - Campaign ID
|
||||
* @param {string} options.subscriberId - Subscriber ID
|
||||
* @returns {Promise} - Promise with the send result
|
||||
*/
|
||||
async sendCampaignEmail({ to, subject, preheader, from, content, campaignId, subscriberId }) {
|
||||
try {
|
||||
// Generate tracking pixel and add unsubscribe link
|
||||
const trackingPixel = this.generateTrackingPixel(campaignId, subscriberId);
|
||||
const unsubscribeLink = this.generateUnsubscribeLink(subscriberId, campaignId);
|
||||
|
||||
// Add preheader if provided
|
||||
let htmlContent = content;
|
||||
if (preheader) {
|
||||
htmlContent = `
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">
|
||||
${preheader}
|
||||
</div>
|
||||
${htmlContent}
|
||||
`;
|
||||
}
|
||||
|
||||
// Add tracking pixel and unsubscribe footer
|
||||
htmlContent = `
|
||||
${htmlContent}
|
||||
<div style="margin-top:20px;padding:20px;font-family:sans-serif;font-size:12px;color:#666;text-align:center;border-top:1px solid #eee;">
|
||||
<p>If you no longer wish to receive these emails, you can <a href="${unsubscribeLink}">unsubscribe here</a>.</p>
|
||||
<p>Sent by ${config.site.domain}</p>
|
||||
</div>
|
||||
${trackingPixel}
|
||||
`;
|
||||
|
||||
// Process links to add tracking
|
||||
htmlContent = await this.processLinks(htmlContent, campaignId, subscriberId);
|
||||
|
||||
// Send the campaign email
|
||||
return await this.sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html: htmlContent,
|
||||
from: from || this.defaultFrom,
|
||||
headers: {
|
||||
'X-Campaign-ID': campaignId,
|
||||
'List-Unsubscribe': `<${unsubscribeLink}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending campaign email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a tracking pixel for email opens
|
||||
* @param {string} campaignId - Campaign ID
|
||||
* @param {string} subscriberId - Subscriber ID
|
||||
* @returns {string} - HTML for the tracking pixel
|
||||
*/
|
||||
generateTrackingPixel(campaignId, subscriberId) {
|
||||
const trackingUrl = `${this.siteUrl}/api/email/track?c=${campaignId}&s=${subscriberId}&t=open`;
|
||||
return `<img src="${trackingUrl}" width="1" height="1" alt="" style="display:block;width:1px;height:1px;border:0;" />`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an unsubscribe link
|
||||
* @param {string} subscriberId - Subscriber ID
|
||||
* @param {string} campaignId - Campaign ID
|
||||
* @returns {string} - Unsubscribe URL
|
||||
*/
|
||||
generateUnsubscribeLink(subscriberId, campaignId) {
|
||||
// Generate an unsubscribe token
|
||||
const token = uuidv4();
|
||||
|
||||
// Store the token in the database (this would be done asynchronously)
|
||||
this.storeUnsubscribeToken(subscriberId, token, campaignId);
|
||||
|
||||
return `${this.siteUrl}/api/subscribers/unsubscribe?token=${token}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Store an unsubscribe token in the database
|
||||
* @param {string} subscriberId - Subscriber ID
|
||||
* @param {string} token - Unsubscribe token
|
||||
* @param {string} campaignId - Campaign ID
|
||||
*/
|
||||
async storeUnsubscribeToken(subscriberId, token, campaignId) {
|
||||
try {
|
||||
// Set token to expire in 60 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 60);
|
||||
|
||||
await query(
|
||||
`INSERT INTO unsubscribe_tokens (subscriber_id, token, campaign_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[subscriberId, token, campaignId, expiresAt]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error storing unsubscribe token:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Process HTML content to add tracking to links
|
||||
* @param {string} content - HTML content
|
||||
* @param {string} campaignId - Campaign ID
|
||||
* @param {string} subscriberId - Subscriber ID
|
||||
* @returns {string} - HTML with tracked links
|
||||
*/
|
||||
async processLinks(content, campaignId, subscriberId) {
|
||||
try {
|
||||
// Basic regex to find links - in a production environment, use a proper HTML parser
|
||||
const linkRegex = /<a[^>]+href=["']([^"']+)["'][^>]*>([^<]+)<\/a>/gi;
|
||||
let match;
|
||||
let processedContent = content;
|
||||
|
||||
// Store links found in this content
|
||||
const links = [];
|
||||
|
||||
// First pass: collect all links
|
||||
while ((match = linkRegex.exec(content)) !== null) {
|
||||
const url = match[1];
|
||||
const text = match[2];
|
||||
|
||||
// Skip tracking for unsubscribe links and mailto links
|
||||
if (url.includes('/unsubscribe') || url.startsWith('mailto:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the link info
|
||||
links.push({ url, text });
|
||||
}
|
||||
|
||||
// Store links in the database and get their IDs
|
||||
const linkIds = await this.storeLinks(links, campaignId);
|
||||
|
||||
// Second pass: replace links with tracked versions
|
||||
let index = 0;
|
||||
processedContent = content.replace(linkRegex, (match, url, text) => {
|
||||
// Skip tracking for unsubscribe links and mailto links
|
||||
if (url.includes('/unsubscribe') || url.startsWith('mailto:')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const linkId = linkIds[index++];
|
||||
if (!linkId) return match; // Skip if no linkId (shouldn't happen)
|
||||
|
||||
// Create tracking URL
|
||||
const trackingUrl = `${this.siteUrl}/api/email/track?c=${campaignId}&s=${subscriberId}&l=${linkId}&t=click&u=${encodeURIComponent(url)}`;
|
||||
|
||||
// Replace the original URL with the tracking URL
|
||||
return match.replace(url, trackingUrl);
|
||||
});
|
||||
|
||||
return processedContent;
|
||||
} catch (error) {
|
||||
console.error('Error processing links:', error);
|
||||
return content; // Return original content on error
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Store links in the database
|
||||
* @param {Array} links - Array of link objects { url, text }
|
||||
* @param {string} campaignId - Campaign ID
|
||||
* @returns {Array} - Array of link IDs
|
||||
*/
|
||||
async storeLinks(links, campaignId) {
|
||||
try {
|
||||
const linkIds = [];
|
||||
|
||||
for (const link of links) {
|
||||
// Check if this link already exists for this campaign
|
||||
const existingLinkCheck = await query(
|
||||
'SELECT id FROM campaign_links WHERE campaign_id = $1 AND url = $2',
|
||||
[campaignId, link.url]
|
||||
);
|
||||
|
||||
if (existingLinkCheck.rows.length > 0) {
|
||||
// Link already exists, use its ID
|
||||
linkIds.push(existingLinkCheck.rows[0].id);
|
||||
} else {
|
||||
// Link doesn't exist, create a new one
|
||||
const linkId = uuidv4();
|
||||
await query(
|
||||
`INSERT INTO campaign_links (id, campaign_id, url, text)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[linkId, campaignId, link.url, link.text]
|
||||
);
|
||||
linkIds.push(linkId);
|
||||
}
|
||||
}
|
||||
|
||||
return linkIds;
|
||||
} catch (error) {
|
||||
console.error('Error storing links:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log an email in the database
|
||||
|
|
|
|||
133
db/init/20-maillinglist.sql
Normal file
133
db/init/20-maillinglist.sql
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
-- Database schema for mailing list and email campaign management
|
||||
|
||||
-- Mailing Lists
|
||||
CREATE TABLE IF NOT EXISTS mailing_lists (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Subscribers
|
||||
CREATE TABLE IF NOT EXISTS subscribers (
|
||||
id UUID PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
status VARCHAR(50) DEFAULT 'active', -- active, unsubscribed, bounced, complained
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_activity_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Mailing List Subscribers (many-to-many)
|
||||
CREATE TABLE IF NOT EXISTS mailing_list_subscribers (
|
||||
list_id UUID REFERENCES mailing_lists(id) ON DELETE CASCADE,
|
||||
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
|
||||
subscribed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (list_id, subscriber_id)
|
||||
);
|
||||
|
||||
-- Email Campaigns
|
||||
CREATE TABLE IF NOT EXISTS email_campaigns (
|
||||
id UUID PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
preheader VARCHAR(255),
|
||||
from_name VARCHAR(255),
|
||||
from_email VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
design TEXT, -- JSON storage for the email editor design
|
||||
list_ids UUID[] NOT NULL, -- Array of list IDs to send to
|
||||
status VARCHAR(50) DEFAULT 'draft', -- draft, scheduled, sending, sent, archived
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
scheduled_for TIMESTAMP,
|
||||
sent_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Campaign Recipients
|
||||
CREATE TABLE IF NOT EXISTS campaign_recipients (
|
||||
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (campaign_id, subscriber_id)
|
||||
);
|
||||
|
||||
-- Campaign Links (for click tracking)
|
||||
CREATE TABLE IF NOT EXISTS campaign_links (
|
||||
id UUID PRIMARY KEY,
|
||||
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
text VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Subscriber Activity
|
||||
CREATE TABLE IF NOT EXISTS subscriber_activity (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
|
||||
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
|
||||
link_id UUID REFERENCES campaign_links(id) ON DELETE SET NULL,
|
||||
type VARCHAR(50) NOT NULL, -- open, click, bounce, complaint, unsubscribe
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
url TEXT, -- For click events
|
||||
details TEXT, -- Additional information
|
||||
bounce_type VARCHAR(50), -- hard, soft
|
||||
CONSTRAINT valid_activity_type CHECK (
|
||||
type IN ('open', 'click', 'bounce', 'complaint', 'unsubscribe', 'sent', 'error')
|
||||
)
|
||||
);
|
||||
|
||||
-- Subscription Confirmations (double opt-in)
|
||||
CREATE TABLE IF NOT EXISTS subscription_confirmations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
confirmed_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Unsubscribe Tokens
|
||||
CREATE TABLE IF NOT EXISTS unsubscribe_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscriber_id UUID REFERENCES subscribers(id) ON DELETE CASCADE,
|
||||
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
|
||||
token VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Email Logs
|
||||
ALTER TABLE email_logs
|
||||
ADD COLUMN IF NOT EXISTS message_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS campaign_id UUID REFERENCES email_campaigns(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS subscriber_id UUID REFERENCES subscribers(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS error_message TEXT;
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX idx_subscribers_email ON subscribers(email);
|
||||
CREATE INDEX idx_subscribers_status ON subscribers(status);
|
||||
CREATE INDEX idx_mailing_list_subscribers_list_id ON mailing_list_subscribers(list_id);
|
||||
CREATE INDEX idx_mailing_list_subscribers_subscriber_id ON mailing_list_subscribers(subscriber_id);
|
||||
CREATE INDEX idx_email_campaigns_status ON email_campaigns(status);
|
||||
CREATE INDEX idx_email_campaigns_scheduled_for ON email_campaigns(scheduled_for);
|
||||
CREATE INDEX idx_campaign_recipients_campaign_id ON campaign_recipients(campaign_id);
|
||||
CREATE INDEX idx_campaign_recipients_subscriber_id ON campaign_recipients(subscriber_id);
|
||||
CREATE INDEX idx_campaign_links_campaign_id ON campaign_links(campaign_id);
|
||||
CREATE INDEX idx_subscriber_activity_subscriber_id ON subscriber_activity(subscriber_id);
|
||||
CREATE INDEX idx_subscriber_activity_campaign_id ON subscriber_activity(campaign_id);
|
||||
CREATE INDEX idx_subscriber_activity_type ON subscriber_activity(type);
|
||||
CREATE INDEX idx_subscriber_activity_timestamp ON subscriber_activity(timestamp);
|
||||
CREATE INDEX idx_subscription_confirmations_token ON subscription_confirmations(token);
|
||||
CREATE INDEX idx_unsubscribe_tokens_token ON unsubscribe_tokens(token);
|
||||
CREATE INDEX idx_email_logs_recipient ON email_logs(recipient);
|
||||
CREATE INDEX idx_email_logs_campaign_id ON email_logs(campaign_id);
|
||||
CREATE INDEX idx_email_logs_status ON email_logs(status);
|
||||
|
||||
|
||||
INSERT INTO mailing_lists (id, name, description) VALUES
|
||||
('1db91b9b-b1f9-4892-80b5-51437d8b6045', 'Default Mailing List', 'This is the default mailing list that new users who accept are attached to, do not delete, feel free to rename');
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
Rock/
|
||||
├── git/
|
||||
project/
|
||||
├── frontend/
|
||||
│ ├── node_modules/
|
||||
│ ├── src/
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Admin/
|
||||
│ │ │ │ ├── BrandingPage.jsx
|
||||
│ │ │ │ ├── ReportsPage.jsx
|
||||
│ │ │ │ ├── ProductEditPage.jsx
|
||||
│ │ │ │ ├── OrdersPage.jsx
|
||||
|
|
@ -42,6 +42,7 @@ Rock/
|
|||
│ │ │ ├── couponService.js
|
||||
│ │ │ ├── productService.js
|
||||
│ │ │ ├── productReviewService.js
|
||||
│ │ │ ├── consentService.js (NEW)
|
||||
│ │ │ ├── settingsAdminService.js
|
||||
│ │ │ ├── imageService.js
|
||||
│ │ │ ├── cartService.js
|
||||
|
|
@ -53,10 +54,12 @@ Rock/
|
|||
│ │ │ ├── ProductReviews.jsx
|
||||
│ │ │ ├── OrderStatusDialog.jsx
|
||||
│ │ │ ├── ImageUploader.jsx
|
||||
│ │ │ ├── CookieConsentPopup.jsx
|
||||
│ │ │ ├── CookieSettingsButton.jsx
|
||||
│ │ │ ├── Footer.jsx
|
||||
│ │ │ ├── CouponInput.jsx
|
||||
│ │ │ ├── EmailDialog.jsx
|
||||
│ │ │ ├── StripePaymentForm.jsx
|
||||
│ │ │ ├── Footer.jsx
|
||||
│ │ │ ├── ProductImage.jsx
|
||||
│ │ │ ├── ProtectedRoute.jsx
|
||||
│ │ │ ├── Notifications.jsx
|
||||
|
|
@ -70,10 +73,11 @@ Rock/
|
|||
│ │ │ ├── reduxHooks.js
|
||||
│ │ │ ├── couponAdminHooks.js
|
||||
│ │ │ ├── settingsAdminHooks.js
|
||||
│ │ │ └── categoryAdminHooks.js
|
||||
│ │ │ ├── categoryAdminHooks.js
|
||||
│ │ │ └── brandingHooks.js
|
||||
│ │ ├── layouts/
|
||||
│ │ │ ├── AdminLayout.jsx
|
||||
│ │ │ ├── MainLayout.jsx
|
||||
│ │ │ ├── AdminLayout.jsx
|
||||
│ │ │ └── AuthLayout.jsx
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── ui/
|
||||
|
|
@ -83,29 +87,27 @@ Rock/
|
|||
│ │ │ ├── auth/
|
||||
│ │ │ │ └── authSlice.js
|
||||
│ │ │ └── theme/
|
||||
│ │ │ ├── index.js
|
||||
│ │ │ └── ThemeProvider.jsx
|
||||
│ │ │ ├── ThemeProvider.jsx
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── imageUtils.js
|
||||
│ │ ├── store/
|
||||
│ │ │ └── index.js
|
||||
│ │ ├── context/
|
||||
│ │ │ └── StripeContext.jsx
|
||||
│ │ ├── assets/
|
||||
│ │ │ ├── App.jsx
|
||||
│ │ │ ├── main.jsx
|
||||
│ │ │ └── config.js
|
||||
│ │ └── public/
|
||||
│ │ ├── favicon.svg
|
||||
│ │ ├── package-lock.json
|
||||
│ │ ├── vite.config.js
|
||||
│ │ ├── README.md
|
||||
│ │ ├── package.json
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── nginx.conf
|
||||
│ │ ├── setup-frontend.sh
|
||||
│ │ ├── index.html
|
||||
│ │ └── .env
|
||||
│ │ └── assets/
|
||||
│ │ ├── App.jsx
|
||||
│ │ ├── main.jsx
|
||||
│ │ └── config.js
|
||||
│ └── public/
|
||||
│ ├── favicon.svg
|
||||
│ ├── vite.config.js
|
||||
│ ├── README.md
|
||||
│ ├── Dockerfile
|
||||
│ ├── nginx.conf
|
||||
│ ├── setup-frontend.sh
|
||||
│ ├── index.html
|
||||
│ └── .env
|
||||
├── backend/
|
||||
│ ├── node_modules/
|
||||
│ ├── src/
|
||||
|
|
@ -113,8 +115,8 @@ Rock/
|
|||
│ │ │ ├── cart.js
|
||||
│ │ │ ├── couponAdmin.js
|
||||
│ │ │ ├── emailTemplatesAdmin.js
|
||||
│ │ │ ├── blogAdmin.js
|
||||
│ │ │ ├── productAdmin.js
|
||||
│ │ │ ├── blogAdmin.js
|
||||
│ │ │ ├── orderAdmin.js
|
||||
│ │ │ ├── settingsAdmin.js
|
||||
│ │ │ ├── blog.js
|
||||
|
|
@ -129,6 +131,7 @@ Rock/
|
|||
│ │ │ ├── shipping.js
|
||||
│ │ │ ├── images.js
|
||||
│ │ │ ├── userOrders.js
|
||||
│ │ │ ├── publicSettings.js
|
||||
│ │ │ └── productAdminImages.js
|
||||
│ │ ├── middleware/
|
||||
│ │ │ ├── upload.js
|
||||
|
|
@ -162,6 +165,7 @@ Rock/
|
|||
│ ├── 16-blog-schema.sql
|
||||
│ ├── 15-coupon.sql
|
||||
│ ├── 17-product-reviews.sql
|
||||
│ ├── 19-branding-settings.sql (NEW)
|
||||
│ ├── 04-product-images.sql
|
||||
│ ├── 09-system-settings.sql
|
||||
│ ├── 14-product-notifications.sql
|
||||
|
|
|
|||
|
|
@ -47,6 +47,16 @@ const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPa
|
|||
const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage'));
|
||||
const BrandingPage = lazy(() => import('@pages/Admin/BrandingPage'));
|
||||
|
||||
const EmailCampaignsPage = lazy(() => import('@pages/Admin/EmailCampaignsPage'));
|
||||
const EmailCampaignEditor = lazy(() => import('@pages/Admin/EmailCampaignEditorPage'));
|
||||
const CampaignSendPage = lazy(() => import('@pages/Admin/CampaignSendPage'));
|
||||
const CampaignAnalyticsPage = lazy(() => import('@pages/Admin/CampaignAnalyticsPage'));
|
||||
const MailingListsPage = lazy(() => import('@pages/Admin/MailingListsPage'));
|
||||
const SubscribersPage = lazy(() => import('@pages/Admin/SubscribersPage'));
|
||||
|
||||
const SubscriptionConfirmPage = lazy(() => import('@pages/SubscriptionConfirmPage'));
|
||||
const UnsubscribePage = lazy(() => import('@pages/UnsubscribePage'));
|
||||
const SubscriptionPreferencesPage = lazy(() => import('@pages/SubscriptionPreferencesPage'));
|
||||
|
||||
|
||||
const projectId = "rcjhrd0t72"
|
||||
|
|
@ -131,6 +141,9 @@ function App() {
|
|||
<Route index element={<HomePage />} />
|
||||
<Route path="products" element={<ProductsPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="confirm-subscription" element={<SubscriptionConfirmPage />} />
|
||||
<Route path="unsubscribe" element={<UnsubscribePage />} />
|
||||
<Route path="subscription-preferences/:token" element={<SubscriptionPreferencesPage />} />
|
||||
<Route path="cart" element={
|
||||
<ProtectedRoute>
|
||||
<CartPage />
|
||||
|
|
@ -191,7 +204,15 @@ function App() {
|
|||
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
|
||||
<Route path="product-reviews" element={<AdminProductReviewsPage />} />
|
||||
<Route path="email-templates" element={<EmailTemplatesPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} /> {/* New Branding Route */}
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
|
||||
<Route path="email-campaigns" element={<EmailCampaignsPage />} />
|
||||
<Route path="email-campaigns/new" element={<EmailCampaignEditor />} />
|
||||
<Route path="email-campaigns/:id" element={<EmailCampaignEditor />} />
|
||||
<Route path="email-campaigns/:id/send" element={<CampaignSendPage />} />
|
||||
<Route path="email-campaigns/:id/analytics" element={<CampaignAnalyticsPage />} />
|
||||
<Route path="mailing-lists" element={<MailingListsPage />} />
|
||||
<Route path="mailing-lists/:listId/subscribers" element={<SubscribersPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch-all route for 404s */}
|
||||
|
|
|
|||
301
frontend/src/components/SubscriptionForm.jsx
Normal file
301
frontend/src/components/SubscriptionForm.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Paper,
|
||||
Divider,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Collapse
|
||||
} from '@mui/material';
|
||||
import {
|
||||
SendOutlined as SendIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
CheckCircleOutline as CheckCircleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import apiClient from '@services/api';
|
||||
|
||||
/**
|
||||
* A reusable subscription form component for mailing lists
|
||||
*
|
||||
* @param {Object} props - Component props
|
||||
* @param {string} props.listId - The ID of the mailing list to subscribe to
|
||||
* @param {string} props.title - Form title
|
||||
* @param {string} props.description - Form description
|
||||
* @param {string} props.buttonText - Submit button text
|
||||
* @param {string} props.successMessage - Message to show after successful submission
|
||||
* @param {boolean} props.collectNames - Whether to collect first and last names
|
||||
* @param {boolean} props.embedded - Whether this form is embedded in another component (styling)
|
||||
* @param {function} props.onSuccess - Callback function after successful subscription
|
||||
*/
|
||||
const SubscriptionForm = ({
|
||||
listId,
|
||||
title = "Subscribe to Our Newsletter",
|
||||
description = "Stay updated with our latest news and offers.",
|
||||
buttonText = "Subscribe",
|
||||
successMessage = "Thank you for subscribing! Please check your email to confirm your subscription.",
|
||||
collectNames = true,
|
||||
embedded = false,
|
||||
onSuccess = null,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showPrivacyInfo, setShowPrivacyInfo] = useState(false);
|
||||
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Clear error when field is edited
|
||||
if (formErrors[name]) {
|
||||
setFormErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const errors = {};
|
||||
|
||||
// Validate email
|
||||
if (!formData.email) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
errors.email = 'Email is invalid';
|
||||
}
|
||||
|
||||
// Validate names if collectNames is true
|
||||
if (collectNames) {
|
||||
if (!formData.firstName) {
|
||||
errors.firstName = 'First name is required';
|
||||
}
|
||||
}
|
||||
|
||||
// Validate terms acceptance
|
||||
if (!acceptedTerms) {
|
||||
errors.terms = 'You must accept the terms to subscribe';
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Reset previous errors/success
|
||||
setError(null);
|
||||
|
||||
// Validate form
|
||||
if (!validate()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Call the API to subscribe
|
||||
await apiClient.post('/api/subscribers/subscribe', {
|
||||
email: formData.email,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
listId: listId
|
||||
});
|
||||
|
||||
// Show success message and reset form
|
||||
setIsSuccess(true);
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
});
|
||||
setAcceptedTerms(false);
|
||||
|
||||
// Call onSuccess callback if provided
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || 'An error occurred. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const containerStyles = embedded
|
||||
? {}
|
||||
: {
|
||||
maxWidth: 500,
|
||||
mx: 'auto',
|
||||
my: 4,
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
boxShadow: 3
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
onSubmit={handleSubmit}
|
||||
sx={containerStyles}
|
||||
elevation={embedded ? 0 : 1}
|
||||
>
|
||||
{/* Form Header */}
|
||||
<Box sx={{ mb: 3, textAlign: embedded ? 'left' : 'center' }}>
|
||||
<Typography variant={embedded ? "h6" : "h5"} component="h2" gutterBottom>
|
||||
{title}
|
||||
</Typography>
|
||||
{description && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Success Message */}
|
||||
{isSuccess && (
|
||||
<Alert
|
||||
icon={<CheckCircleIcon fontSize="inherit" />}
|
||||
severity="success"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form Fields */}
|
||||
<Box sx={{ display: isSuccess ? 'none' : 'block' }}>
|
||||
{collectNames && (
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
error={!!formErrors.firstName}
|
||||
helperText={formErrors.firstName}
|
||||
fullWidth
|
||||
required
|
||||
margin="normal"
|
||||
size={embedded ? "small" : "medium"}
|
||||
/>
|
||||
<TextField
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
error={!!formErrors.lastName}
|
||||
helperText={formErrors.lastName}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
size={embedded ? "small" : "medium"}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
name="email"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
error={!!formErrors.email}
|
||||
helperText={formErrors.email}
|
||||
fullWidth
|
||||
required
|
||||
margin="normal"
|
||||
size={embedded ? "small" : "medium"}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title="We'll send a confirmation email to verify your address.">
|
||||
<IconButton edge="end" size="small" tabIndex={-1}>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Privacy Info & Terms Checkbox */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={acceptedTerms}
|
||||
onChange={(e) => setAcceptedTerms(e.target.checked)}
|
||||
size={embedded ? "small" : "medium"}
|
||||
/>
|
||||
}
|
||||
label="I agree to receive emails and accept the terms"
|
||||
/>
|
||||
<Tooltip title="Click for more information">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setShowPrivacyInfo(!showPrivacyInfo)}
|
||||
>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{formErrors.terms && (
|
||||
<Typography variant="caption" color="error" sx={{ ml: 2 }}>
|
||||
{formErrors.terms}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Collapse in={showPrivacyInfo}>
|
||||
<Box sx={{ mt: 1, p: 2, bgcolor: 'background.paper', borderRadius: 1, fontSize: 'small' }}>
|
||||
<Typography variant="body2" paragraph>
|
||||
By subscribing, you agree to receive marketing emails from us. We respect your privacy and will never share your information with third parties.
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
You can unsubscribe at any time by clicking the unsubscribe link in the footer of our emails. For information about our privacy practices, visit our website.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Box sx={{ mt: 3, display: 'flex', justifyContent: embedded ? 'flex-start' : 'center' }}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={isSubmitting}
|
||||
startIcon={isSubmitting ? <CircularProgress size={20} /> : <SendIcon />}
|
||||
>
|
||||
{isSubmitting ? 'Subscribing...' : buttonText}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionForm;
|
||||
640
frontend/src/hooks/emailCampaignHooks.js
Normal file
640
frontend/src/hooks/emailCampaignHooks.js
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '@services/api';
|
||||
import { useNotification } from './reduxHooks';
|
||||
|
||||
/**
|
||||
* Hook for fetching all email campaigns
|
||||
* @param {string} status - Optional filter by status
|
||||
*/
|
||||
export const useEmailCampaigns = (status) => {
|
||||
return useQuery({
|
||||
queryKey: ['email-campaigns', status],
|
||||
queryFn: async () => {
|
||||
const params = {};
|
||||
if (status) params.status = status;
|
||||
const response = await apiClient.get('/admin/email-campaigns', { params });
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching a single email campaign
|
||||
* @param {string} id - Campaign ID
|
||||
*/
|
||||
export const useEmailCampaign = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['email-campaign', id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/admin/email-campaigns/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!id
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating a new email campaign
|
||||
*/
|
||||
export const useCreateEmailCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (campaignData) => {
|
||||
const response = await apiClient.post('/admin/email-campaigns', campaignData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaigns'] });
|
||||
notification.showNotification('Campaign created successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create campaign',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for updating an existing email campaign
|
||||
*/
|
||||
export const useUpdateEmailCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, campaignData }) => {
|
||||
const response = await apiClient.put(`/admin/email-campaigns/${id}`, campaignData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaign', variables.id] });
|
||||
notification.showNotification('Campaign updated successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update campaign',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for deleting an email campaign
|
||||
*/
|
||||
export const useDeleteEmailCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id) => {
|
||||
const response = await apiClient.delete(`/admin/email-campaigns/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaigns'] });
|
||||
notification.showNotification('Campaign deleted successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete campaign',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for duplicating an email campaign
|
||||
*/
|
||||
export const useDuplicateEmailCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id) => {
|
||||
const response = await apiClient.post(`/admin/email-campaigns/${id}/duplicate`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaigns'] });
|
||||
notification.showNotification('Campaign duplicated successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to duplicate campaign',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for sending an email campaign
|
||||
*/
|
||||
export const useSendCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id) => {
|
||||
const response = await apiClient.post(`/admin/email-campaigns/${id}/send`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaign', data.id] });
|
||||
notification.showNotification('Campaign sent successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to send campaign',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for scheduling an email campaign
|
||||
*/
|
||||
export const useScheduleCampaign = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ campaignId, scheduledDate }) => {
|
||||
const response = await apiClient.post(`/admin/email-campaigns/${campaignId}/schedule`, {
|
||||
scheduledDate
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-campaign', data.id] });
|
||||
notification.showNotification('Campaign scheduled successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to schedule campaign',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for sending a preview/test email
|
||||
*/
|
||||
export const usePreviewCampaign = () => {
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ campaignId, email }) => {
|
||||
const response = await apiClient.post(`/admin/email-campaigns/${campaignId}/preview`, {
|
||||
email
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notification.showNotification('Test email sent successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to send test email',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching campaign analytics
|
||||
*/
|
||||
export const useCampaignAnalytics = (campaignId) => {
|
||||
return useQuery({
|
||||
queryKey: ['campaign-analytics', campaignId],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/admin/email-campaigns/${campaignId}/analytics`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!campaignId
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching campaign subscriber activity
|
||||
*/
|
||||
export const useCampaignSubscriberActivity = (
|
||||
campaignId,
|
||||
page = 0,
|
||||
pageSize = 25,
|
||||
searchTerm = '',
|
||||
activityType = 'all'
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['campaign-activity', campaignId, page, pageSize, searchTerm, activityType],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
search: searchTerm,
|
||||
type: activityType === 'all' ? undefined : activityType
|
||||
};
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/email-campaigns/${campaignId}/activity`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!campaignId && tabValue === 2 // Only fetch when on the activity tab
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for exporting campaign report
|
||||
*/
|
||||
export const useExportCampaignReport = () => {
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (campaignId) => {
|
||||
const response = await apiClient.get(`/admin/email-campaigns/${campaignId}/export`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Create a download link and trigger the download
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `campaign-report-${campaignId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notification.showNotification('Report exported successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to export report',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching all mailing lists
|
||||
*/
|
||||
export const useMailingLists = () => {
|
||||
return useQuery({
|
||||
queryKey: ['mailing-lists'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/admin/mailing-lists');
|
||||
return response.data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching a single mailing list
|
||||
*/
|
||||
export const useMailingList = (listId) => {
|
||||
return useQuery({
|
||||
queryKey: ['mailing-list', listId],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/admin/mailing-lists/${listId}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!listId
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating a new mailing list
|
||||
*/
|
||||
export const useCreateMailingList = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (listData) => {
|
||||
const response = await apiClient.post('/admin/mailing-lists', listData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-lists'] });
|
||||
notification.showNotification('Mailing list created successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create mailing list',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for updating a mailing list
|
||||
*/
|
||||
export const useUpdateMailingList = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, listData }) => {
|
||||
const response = await apiClient.put(`/admin/mailing-lists/${id}`, listData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-lists'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-list', data.id] });
|
||||
notification.showNotification('Mailing list updated successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update mailing list',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for deleting a mailing list
|
||||
*/
|
||||
export const useDeleteMailingList = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id) => {
|
||||
const response = await apiClient.delete(`/admin/mailing-lists/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-lists'] });
|
||||
notification.showNotification('Mailing list deleted successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete mailing list',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for importing subscribers to a list
|
||||
*/
|
||||
export const useImportSubscribers = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (formData) => {
|
||||
const response = await apiClient.post('/admin/mailing-lists/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-lists'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-list', data.listId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscribers', data.listId] });
|
||||
notification.showNotification(`${data.importedCount} subscribers imported successfully`, 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to import subscribers',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for exporting subscribers from a list
|
||||
*/
|
||||
export const useExportSubscribers = () => {
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (listId) => {
|
||||
const response = await apiClient.get(`/admin/mailing-lists/${listId}/export`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Create a download link and trigger the download
|
||||
const blob = new Blob([response.data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `subscribers-${listId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
notification.showNotification('Subscribers exported successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to export subscribers',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching list subscribers
|
||||
*/
|
||||
export const useSubscribers = (
|
||||
listId,
|
||||
page = 0,
|
||||
pageSize = 25,
|
||||
searchTerm = '',
|
||||
status = 'all'
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['subscribers', listId, page, pageSize, searchTerm, status],
|
||||
queryFn: async () => {
|
||||
const params = {
|
||||
page,
|
||||
pageSize,
|
||||
search: searchTerm,
|
||||
status: status === 'all' ? undefined : status
|
||||
};
|
||||
|
||||
const response = await apiClient.get(
|
||||
`/admin/mailing-lists/${listId}/subscribers`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!listId
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for adding a subscriber to a list
|
||||
*/
|
||||
export const useAddSubscriber = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ listId, subscriberData }) => {
|
||||
const response = await apiClient.post(`/admin/mailing-lists/${listId}/subscribers`, subscriberData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscribers', data.listId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mailing-list', data.listId] });
|
||||
notification.showNotification('Subscriber added successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to add subscriber',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for updating a subscriber
|
||||
*/
|
||||
export const useUpdateSubscriber = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, subscriberData }) => {
|
||||
const response = await apiClient.put(`/admin/subscribers/${id}`, subscriberData);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscribers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriber', data.id] });
|
||||
notification.showNotification('Subscriber updated successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update subscriber',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for deleting a subscriber
|
||||
*/
|
||||
export const useDeleteSubscriber = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id) => {
|
||||
const response = await apiClient.delete(`/admin/subscribers/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscribers'] });
|
||||
notification.showNotification('Subscriber deleted successfully', 'success');
|
||||
return data;
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete subscriber',
|
||||
'error'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching subscriber activity
|
||||
*/
|
||||
export const useSubscriberActivity = (subscriberId, enabled = false) => {
|
||||
return useQuery({
|
||||
queryKey: ['subscriber-activity', subscriberId],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get(`/admin/subscribers/${subscriberId}/activity`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!subscriberId && enabled
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
useEmailCampaigns,
|
||||
useEmailCampaign,
|
||||
useCreateEmailCampaign,
|
||||
useUpdateEmailCampaign,
|
||||
useDeleteEmailCampaign,
|
||||
useDuplicateEmailCampaign,
|
||||
useSendCampaign,
|
||||
useScheduleCampaign,
|
||||
usePreviewCampaign,
|
||||
useCampaignAnalytics,
|
||||
useCampaignSubscriberActivity,
|
||||
useExportCampaignReport,
|
||||
useMailingLists,
|
||||
useMailingList,
|
||||
useCreateMailingList,
|
||||
useUpdateMailingList,
|
||||
useDeleteMailingList,
|
||||
useImportSubscribers,
|
||||
useExportSubscribers,
|
||||
useSubscribers,
|
||||
useAddSubscriber,
|
||||
useUpdateSubscriber,
|
||||
useDeleteSubscriber,
|
||||
useSubscriberActivity
|
||||
};
|
||||
|
|
@ -87,6 +87,8 @@ const AdminLayout = () => {
|
|||
{ text: 'Blog', icon: <BookIcon />, path: '/admin/blog' },
|
||||
{ text: 'Blog Comments', icon: <CommentIcon />, path: '/admin/blog-comments' },
|
||||
{ text: 'Email Templates', icon: <EmailIcon />, path: '/admin/email-templates' },
|
||||
{ text: 'Email Campaigns', icon: <EmailIcon />, path: '/admin/email-campaigns' },
|
||||
{ text: 'Mailing List', icon: <EmailIcon />, path: '/admin/mailing-lists' },
|
||||
{ text: 'Branding', icon: <BrushIcon />, path: '/admin/branding' },
|
||||
{ text: 'Settings', icon: <SettingsIcon />, path: '/admin/settings' },
|
||||
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
|
||||
|
|
|
|||
718
frontend/src/pages/Admin/CampaignAnalyticsPage.jsx
Normal file
718
frontend/src/pages/Admin/CampaignAnalyticsPage.jsx
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
Tabs,
|
||||
Tab,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Tooltip,
|
||||
Chip,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Breadcrumbs,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
FileDownload as DownloadIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
FilterList as FilterIcon,
|
||||
MoreVert as MoreVertIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
useEmailCampaign,
|
||||
useCampaignAnalytics,
|
||||
useCampaignSubscriberActivity,
|
||||
useExportCampaignReport
|
||||
} from '../../hooks/emailCampaignHooks';
|
||||
|
||||
// Import chart components
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip as RechartsTooltip,
|
||||
Cell,
|
||||
Legend
|
||||
} from 'recharts';
|
||||
|
||||
const CampaignAnalyticsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
const [filterAnchorEl, setFilterAnchorEl] = useState(null);
|
||||
const [activityTypeFilter, setActivityTypeFilter] = useState('all');
|
||||
const [timePeriod, setTimePeriod] = useState('all');
|
||||
|
||||
// Fetch campaign data
|
||||
const { data: campaign, isLoading: campaignLoading, error: campaignError } = useEmailCampaign(id);
|
||||
|
||||
// Fetch analytics data
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useCampaignAnalytics(id);
|
||||
|
||||
// Fetch subscriber activity data with pagination
|
||||
const {
|
||||
data: activityData,
|
||||
isLoading: activityLoading,
|
||||
error: activityError
|
||||
} = useCampaignSubscriberActivity(
|
||||
id, page, rowsPerPage, searchTerm, activityTypeFilter
|
||||
);
|
||||
|
||||
// Export report mutation
|
||||
const exportReport = useExportCampaignReport();
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(0); // Reset page when search term changes
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = (event, newValue) => {
|
||||
setPage(newValue);
|
||||
};
|
||||
|
||||
// Handle rows per page change
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Handle filter menu
|
||||
const handleFilterClick = (event) => {
|
||||
setFilterAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleFilterClose = () => {
|
||||
setFilterAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleFilterSelect = (filterType) => {
|
||||
setActivityTypeFilter(filterType);
|
||||
setFilterAnchorEl(null);
|
||||
setPage(0); // Reset page when filter changes
|
||||
};
|
||||
|
||||
// Handle time period change
|
||||
const handleTimePeriodChange = (period) => {
|
||||
setTimePeriod(period);
|
||||
};
|
||||
|
||||
// Export campaign report
|
||||
const handleExportReport = async () => {
|
||||
try {
|
||||
await exportReport.mutateAsync(id);
|
||||
} catch (error) {
|
||||
console.error('Failed to export report:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (campaignLoading || analyticsLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (campaignError || analyticsError) {
|
||||
return <Alert severity="error" sx={{ my: 2 }}>{campaignError?.message || analyticsError?.message}</Alert>;
|
||||
}
|
||||
|
||||
// Campaign not found
|
||||
if (!campaign) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ my: 2 }}>
|
||||
Campaign not found. Please select a valid campaign.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Color constants for charts
|
||||
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#A4DE6C'];
|
||||
|
||||
// Calculate metrics for display
|
||||
const openRate = analytics?.delivered > 0
|
||||
? ((analytics.opened / analytics.delivered) * 100).toFixed(2)
|
||||
: '0';
|
||||
|
||||
const clickRate = analytics?.opened > 0
|
||||
? ((analytics.clicked / analytics.opened) * 100).toFixed(2)
|
||||
: '0';
|
||||
|
||||
const bounceRate = analytics?.sent > 0
|
||||
? ((analytics.bounced / analytics.sent) * 100).toFixed(2)
|
||||
: '0';
|
||||
|
||||
const unsubscribeRate = analytics?.delivered > 0
|
||||
? ((analytics.unsubscribed / analytics.delivered) * 100).toFixed(2)
|
||||
: '0';
|
||||
|
||||
// Generate data for pie chart
|
||||
const deliveryPieData = [
|
||||
{ name: 'Delivered', value: analytics?.delivered || 0 },
|
||||
{ name: 'Bounced', value: analytics?.bounced || 0 },
|
||||
];
|
||||
|
||||
// Generate data for engagement pie chart
|
||||
const engagementPieData = [
|
||||
{ name: 'Opened', value: analytics?.opened || 0 },
|
||||
{ name: 'Clicked', value: analytics?.clicked || 0 },
|
||||
{ name: 'Unopened', value: (analytics?.delivered || 0) - (analytics?.opened || 0) },
|
||||
];
|
||||
|
||||
const opensByHourData = analytics?.opens_by_hour || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with breadcrumbs and back button */}
|
||||
<Box mb={3}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin/email-campaigns')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Back to Campaigns
|
||||
</Button>
|
||||
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link component={RouterLink} to="/admin" color="inherit">
|
||||
Admin
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/admin/email-campaigns" color="inherit">
|
||||
Email Campaigns
|
||||
</Link>
|
||||
<Typography color="text.primary">Analytics</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h4" component="h1">
|
||||
Campaign Analytics: {campaign.name}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleExportReport}
|
||||
disabled={exportReport.isLoading}
|
||||
>
|
||||
{exportReport.isLoading ? <CircularProgress size={24} /> : 'Export Report'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Campaign info card */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="subtitle2" color="text.secondary">Subject:</Typography>
|
||||
<Typography variant="body1" gutterBottom>{campaign.subject}</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="subtitle2" color="text.secondary">Sent From:</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{campaign.from_name} ({campaign.from_email})
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="subtitle2" color="text.secondary">Sent Date:</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{formatDate(campaign.sent_at)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="subtitle2" color="text.secondary">Status:</Typography>
|
||||
<Chip
|
||||
label={campaign.status}
|
||||
color={
|
||||
campaign.status === 'draft' ? 'default' :
|
||||
campaign.status === 'scheduled' ? 'warning' :
|
||||
campaign.status === 'sending' ? 'info' :
|
||||
campaign.status === 'sent' ? 'success' :
|
||||
'default'
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="subtitle2" color="text.secondary">Recipients:</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{analytics?.sent || 0} subscribers
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<Typography variant="subtitle2" color="text.secondary">List:</Typography>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
{campaign.list_name || 'Multiple Lists'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Key metrics cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Open Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" color="primary">
|
||||
{openRate}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{analytics?.opened || 0} of {analytics?.delivered || 0} delivered
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Click Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" color="primary">
|
||||
{clickRate}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{analytics?.clicked || 0} of {analytics?.opened || 0} opened
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Bounce Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" color={bounceRate > 5 ? "error" : "primary"}>
|
||||
{bounceRate}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{analytics?.bounced || 0} of {analytics?.sent || 0} sent
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Unsubscribe Rate
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" color="primary">
|
||||
{unsubscribeRate}%
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{analytics?.unsubscribed || 0} of {analytics?.delivered || 0} delivered
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Tabs for different data views */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={handleTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label="Overview" />
|
||||
<Tab label="Link Performance" />
|
||||
<Tab label="Subscriber Activity" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Tab Content */}
|
||||
{/* Overview Tab */}
|
||||
{tabValue === 0 && (
|
||||
<Grid container spacing={3}>
|
||||
{/* Time series chart - opens over time */}
|
||||
<Grid item xs={12} md={8}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Opens Over Time</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={timePeriod === 'all' ? 'contained' : 'outlined'}
|
||||
onClick={() => handleTimePeriodChange('all')}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={timePeriod === '24h' ? 'contained' : 'outlined'}
|
||||
onClick={() => handleTimePeriodChange('24h')}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
24 Hours
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={timePeriod === '7d' ? 'contained' : 'outlined'}
|
||||
onClick={() => handleTimePeriodChange('7d')}
|
||||
>
|
||||
7 Days
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ height: 300 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={opensByHourData}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 0 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return format(date, 'h aaa');
|
||||
}}
|
||||
/>
|
||||
<YAxis />
|
||||
<RechartsTooltip
|
||||
formatter={(value, name) => [value, 'Opens']}
|
||||
labelFormatter={(label) => format(new Date(label), 'MMM dd, yyyy h:mm a')}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="opens"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Pie charts for Delivery and Engagement */}
|
||||
<Grid item xs={12} md={4}>
|
||||
<Paper sx={{ p: 2, height: '100%' }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Delivery</Typography>
|
||||
<Box sx={{ height: 160, mb: 3 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={deliveryPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={70}
|
||||
fill="#8884d8"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{deliveryPieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>Engagement</Typography>
|
||||
<Box sx={{ height: 160 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={engagementPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={70}
|
||||
fill="#8884d8"
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
>
|
||||
{engagementPieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Link Performance Tab */}
|
||||
{tabValue === 1 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Link Performance</Typography>
|
||||
|
||||
{analytics?.links && analytics.links.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>URL</TableCell>
|
||||
<TableCell>Link Text</TableCell>
|
||||
<TableCell align="right">Clicks</TableCell>
|
||||
<TableCell align="right">Unique Clicks</TableCell>
|
||||
<TableCell align="right">Click Rate</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{analytics.links.map((link) => (
|
||||
<TableRow key={link.id}>
|
||||
<TableCell>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
maxWidth: '250px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{link.url}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{link.text || '-'}</TableCell>
|
||||
<TableCell align="right">{link.clicks}</TableCell>
|
||||
<TableCell align="right">{link.unique_clicks}</TableCell>
|
||||
<TableCell align="right">
|
||||
{analytics.opened > 0
|
||||
? ((link.unique_clicks / analytics.opened) * 100).toFixed(2)
|
||||
: '0'}%
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Copy URL">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => navigator.clipboard.writeText(link.url)}
|
||||
>
|
||||
<CopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
No link data available for this campaign.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Subscriber Activity Tab */}
|
||||
{tabValue === 2 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Subscriber Activity</Typography>
|
||||
|
||||
<Box sx={{ mb: 3, display: 'flex', gap: 2 }}>
|
||||
<TextField
|
||||
placeholder="Search by email or name..."
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="clear search"
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
startIcon={<FilterIcon />}
|
||||
variant="outlined"
|
||||
onClick={handleFilterClick}
|
||||
size="small"
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Activity Type Filter Menu */}
|
||||
<Menu
|
||||
anchorEl={filterAnchorEl}
|
||||
open={Boolean(filterAnchorEl)}
|
||||
onClose={handleFilterClose}
|
||||
>
|
||||
<MenuItem onClick={() => handleFilterSelect('all')}>
|
||||
All Activities
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleFilterSelect('open')}>
|
||||
Opens
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleFilterSelect('click')}>
|
||||
Clicks
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleFilterSelect('bounce')}>
|
||||
Bounces
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleFilterSelect('unsubscribe')}>
|
||||
Unsubscribes
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Activity Table */}
|
||||
{activityLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : activityData?.activities && activityData.activities.length > 0 ? (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Activity</TableCell>
|
||||
<TableCell>Timestamp</TableCell>
|
||||
<TableCell>Details</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{activityData.activities.map((activity) => (
|
||||
<TableRow key={activity.id}>
|
||||
<TableCell>{activity.email}</TableCell>
|
||||
<TableCell>
|
||||
{activity.first_name || activity.last_name
|
||||
? `${activity.first_name || ''} ${activity.last_name || ''}`.trim()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={activity.type}
|
||||
size="small"
|
||||
color={
|
||||
activity.type === 'open' ? 'info' :
|
||||
activity.type === 'click' ? 'success' :
|
||||
activity.type === 'bounce' ? 'warning' :
|
||||
activity.type === 'unsubscribe' ? 'error' :
|
||||
'default'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(activity.timestamp)}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{activity.type === 'click'
|
||||
? `Clicked: ${activity.url || 'Unknown URL'}`
|
||||
: activity.type === 'bounce'
|
||||
? `Bounce type: ${activity.bounce_type || 'Unknown'}`
|
||||
: activity.details || '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={activityData.totalCount || 0}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[25, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Alert severity="info">
|
||||
No activity data available for this campaign{searchTerm ? ' matching your search criteria' : ''}.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampaignAnalyticsPage;
|
||||
628
frontend/src/pages/Admin/CampaignSendPage.jsx
Normal file
628
frontend/src/pages/Admin/CampaignSendPage.jsx
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Chip,
|
||||
Divider,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
MenuItem,
|
||||
Select,
|
||||
InputLabel
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Send as SendIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Preview as PreviewIcon,
|
||||
InfoOutlined as InfoIcon,
|
||||
WarningAmber as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { format, addHours, addDays, setHours, setMinutes } from 'date-fns';
|
||||
import {
|
||||
useEmailCampaign,
|
||||
useMailingList,
|
||||
useSendCampaign,
|
||||
useScheduleCampaign,
|
||||
usePreviewCampaign
|
||||
} from '@hooks/emailCampaignHooks';
|
||||
|
||||
const CampaignSendPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
|
||||
// Stepper state
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const steps = ['Review Campaign', 'Select Delivery Options', 'Confirm & Send'];
|
||||
|
||||
// Custom date time picker state
|
||||
const initialDate = addHours(new Date(), 1);
|
||||
const [scheduledDate, setScheduledDate] = useState(initialDate);
|
||||
const [selectedDate, setSelectedDate] = useState(format(initialDate, 'yyyy-MM-dd'));
|
||||
const [selectedHour, setSelectedHour] = useState(initialDate.getHours());
|
||||
const [selectedMinute, setSelectedMinute] = useState(initialDate.getMinutes());
|
||||
|
||||
// Delivery options
|
||||
const [deliveryOption, setDeliveryOption] = useState('send_now');
|
||||
const [confirmChecked, setConfirmChecked] = useState(false);
|
||||
|
||||
// Preview dialog
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [testEmailAddress, setTestEmailAddress] = useState('');
|
||||
|
||||
// Fetch campaign and related data
|
||||
const { data: campaign, isLoading: campaignLoading, error: campaignError } = useEmailCampaign(id);
|
||||
|
||||
// Load lists information for selected lists
|
||||
const { data: listData, isLoading: listLoading } = useMailingList(
|
||||
campaign?.list_ids?.length > 0 ? campaign.list_ids[0] : null
|
||||
);
|
||||
|
||||
// Mutations
|
||||
const sendCampaign = useSendCampaign();
|
||||
const scheduleCampaign = useScheduleCampaign();
|
||||
const previewCampaign = usePreviewCampaign();
|
||||
|
||||
// Generate hours and minutes for dropdowns
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const minutes = Array.from({ length: 60 }, (_, i) => i);
|
||||
|
||||
// Update the scheduledDate when date/time components change
|
||||
useEffect(() => {
|
||||
try {
|
||||
const dateObj = new Date(selectedDate);
|
||||
dateObj.setHours(selectedHour);
|
||||
dateObj.setMinutes(selectedMinute);
|
||||
setScheduledDate(dateObj);
|
||||
} catch (error) {
|
||||
console.error("Invalid date selection:", error);
|
||||
}
|
||||
}, [selectedDate, selectedHour, selectedMinute]);
|
||||
|
||||
// Handle step changes
|
||||
const handleNext = () => {
|
||||
setActiveStep((prevStep) => Math.min(prevStep + 1, steps.length - 1));
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep((prevStep) => Math.max(prevStep - 1, 0));
|
||||
};
|
||||
|
||||
// Handle delivery option change
|
||||
const handleDeliveryOptionChange = (event) => {
|
||||
setDeliveryOption(event.target.value);
|
||||
};
|
||||
|
||||
// Handle date change
|
||||
const handleDateChange = (event) => {
|
||||
setSelectedDate(event.target.value);
|
||||
};
|
||||
|
||||
// Handle hour change
|
||||
const handleHourChange = (event) => {
|
||||
setSelectedHour(Number(event.target.value));
|
||||
};
|
||||
|
||||
// Handle minute change
|
||||
const handleMinuteChange = (event) => {
|
||||
setSelectedMinute(Number(event.target.value));
|
||||
};
|
||||
|
||||
// Handle preview
|
||||
const handleOpenPreview = () => {
|
||||
setPreviewDialogOpen(true);
|
||||
};
|
||||
|
||||
// Send test email
|
||||
const handleSendTest = async () => {
|
||||
if (!testEmailAddress || !campaign) return;
|
||||
|
||||
try {
|
||||
await previewCampaign.mutateAsync({
|
||||
campaignId: campaign.id,
|
||||
email: testEmailAddress
|
||||
});
|
||||
|
||||
// Clear the field after successful send
|
||||
if (!previewCampaign.error) {
|
||||
setTestEmailAddress('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send test email:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle final campaign send/schedule
|
||||
const handleSendCampaign = async () => {
|
||||
if (!campaign) return;
|
||||
|
||||
try {
|
||||
if (deliveryOption === 'send_now') {
|
||||
await sendCampaign.mutateAsync(campaign.id);
|
||||
} else {
|
||||
await scheduleCampaign.mutateAsync({
|
||||
campaignId: campaign.id,
|
||||
scheduledDate: scheduledDate.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate back to campaigns list on success
|
||||
if (!sendCampaign.error && !scheduleCampaign.error) {
|
||||
navigate('/admin/email-campaigns');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send/schedule campaign:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get estimated recipient count
|
||||
const getRecipientCount = () => {
|
||||
if (!campaign || !campaign.list_ids || campaign.list_ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return listData?.subscriber_count || 0;
|
||||
};
|
||||
|
||||
// Format hour for display (add leading zero if needed)
|
||||
const formatTimeValue = (value) => {
|
||||
return value.toString().padStart(2, '0');
|
||||
};
|
||||
|
||||
// Validate that selected date is in the future
|
||||
const isDateInPast = () => {
|
||||
const now = new Date();
|
||||
return scheduledDate < now;
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (campaignLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (campaignError) {
|
||||
return <Alert severity="error" sx={{ my: 2 }}>{campaignError.message}</Alert>;
|
||||
}
|
||||
|
||||
// Campaign not found
|
||||
if (!campaign) {
|
||||
return (
|
||||
<Alert severity="warning" sx={{ my: 2 }}>
|
||||
Campaign not found. Please select a valid campaign.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with back button */}
|
||||
<Box mb={3} display="flex" alignItems="center">
|
||||
<IconButton onClick={() => navigate(`/admin/email-campaigns/${id}`)} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h4">
|
||||
Send Campaign: {campaign.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{/* Step content */}
|
||||
<Box mb={4}>
|
||||
{/* Step 1: Review Campaign */}
|
||||
{activeStep === 0 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Review Campaign Details</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Campaign Details
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">Name:</Typography>
|
||||
<Typography variant="body2">{campaign.name}</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">Subject:</Typography>
|
||||
<Typography variant="body2">{campaign.subject}</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">From:</Typography>
|
||||
<Typography variant="body2">
|
||||
{campaign.from_name} ({campaign.from_email})
|
||||
</Typography>
|
||||
|
||||
{campaign.preheader && (
|
||||
<>
|
||||
<Typography variant="body2" color="text.secondary">Preheader:</Typography>
|
||||
<Typography variant="body2">{campaign.preheader}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
|
||||
Audience
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Mailing Lists:
|
||||
</Typography>
|
||||
|
||||
{listLoading ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${listData?.name} (${listData?.subscriber_count || 0} subscribers)`}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
<strong>Estimated Recipients:</strong> {getRecipientCount()}
|
||||
</Typography>
|
||||
|
||||
{getRecipientCount() === 0 && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
No recipients in selected mailing lists. Your campaign won't be delivered to anyone.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button onClick={handleOpenPreview} startIcon={<PreviewIcon />}>
|
||||
Preview Campaign
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={getRecipientCount() === 0}
|
||||
>
|
||||
Next: Delivery Options
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 2: Delivery Options */}
|
||||
{activeStep === 1 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Delivery Options</Typography>
|
||||
|
||||
<FormControl component="fieldset" sx={{ mb: 4 }}>
|
||||
<FormLabel component="legend">When should this campaign be sent?</FormLabel>
|
||||
<RadioGroup
|
||||
value={deliveryOption}
|
||||
onChange={handleDeliveryOptionChange}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="send_now"
|
||||
control={<Radio />}
|
||||
label="Send immediately"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="schedule"
|
||||
control={<Radio />}
|
||||
label="Schedule for later"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{deliveryOption === 'schedule' && (
|
||||
<Box sx={{ mb: 4, ml: 4 }}>
|
||||
{/* Custom Date Time Picker */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
label="Select Date"
|
||||
type="date"
|
||||
fullWidth
|
||||
value={selectedDate}
|
||||
onChange={handleDateChange}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
inputProps={{ min: format(new Date(), 'yyyy-MM-dd') }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="hour-select-label">Hour</InputLabel>
|
||||
<Select
|
||||
labelId="hour-select-label"
|
||||
value={selectedHour}
|
||||
onChange={handleHourChange}
|
||||
label="Hour"
|
||||
>
|
||||
{hours.map((hour) => (
|
||||
<MenuItem key={hour} value={hour}>
|
||||
{formatTimeValue(hour)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="minute-select-label">Minute</InputLabel>
|
||||
<Select
|
||||
labelId="minute-select-label"
|
||||
value={selectedMinute}
|
||||
onChange={handleMinuteChange}
|
||||
label="Minute"
|
||||
>
|
||||
{minutes.map((minute) => (
|
||||
<MenuItem key={minute} value={minute}>
|
||||
{formatTimeValue(minute)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{isDateInPast() && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Selected time is in the past. Please choose a future date and time.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Selected Date/Time: {format(scheduledDate, 'PPpp')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
|
||||
Campaigns are sent in your local timezone: {Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Send Test Email
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
Send a test email to verify how your campaign will look before sending to your list.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<TextField
|
||||
label="Test Email Address"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={testEmailAddress}
|
||||
onChange={(e) => setTestEmailAddress(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleSendTest}
|
||||
disabled={!testEmailAddress || previewCampaign.isLoading}
|
||||
>
|
||||
{previewCampaign.isLoading ? <CircularProgress size={24} /> : 'Send Test'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{previewCampaign.isSuccess && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
Test email sent successfully!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{previewCampaign.error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{previewCampaign.error.message || 'Failed to send test email'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={deliveryOption === 'schedule' && isDateInPast()}
|
||||
>
|
||||
Next: Review & Send
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm & Send */}
|
||||
{activeStep === 2 && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>Confirm & Send</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1">Please review your campaign before sending</Typography>
|
||||
<Typography variant="body2">
|
||||
Once a campaign is sent, it cannot be recalled or edited.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Sending Summary</Typography>
|
||||
|
||||
<List disablePadding>
|
||||
<ListItem divider>
|
||||
<ListItemText
|
||||
primary="Campaign"
|
||||
secondary={campaign.name}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem divider>
|
||||
<ListItemText
|
||||
primary="Subject Line"
|
||||
secondary={campaign.subject}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem divider>
|
||||
<ListItemText
|
||||
primary="From"
|
||||
secondary={`${campaign.from_name} (${campaign.from_email})`}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem divider>
|
||||
<ListItemText
|
||||
primary="Recipients"
|
||||
secondary={`${getRecipientCount()} subscribers`}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Delivery"
|
||||
secondary={
|
||||
deliveryOption === 'send_now'
|
||||
? 'Send immediately'
|
||||
: `Scheduled for ${format(scheduledDate, 'PPpp')}`
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={confirmChecked}
|
||||
onChange={(e) => setConfirmChecked(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="I confirm that this campaign is ready to send"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={deliveryOption === 'send_now' ? <SendIcon /> : <ScheduleIcon />}
|
||||
onClick={handleSendCampaign}
|
||||
disabled={!confirmChecked || sendCampaign.isLoading || scheduleCampaign.isLoading || (deliveryOption === 'schedule' && isDateInPast())}
|
||||
>
|
||||
{sendCampaign.isLoading || scheduleCampaign.isLoading ? (
|
||||
<CircularProgress size={24} />
|
||||
) : (
|
||||
deliveryOption === 'send_now' ? 'Send Campaign' : 'Schedule Campaign'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{(sendCampaign.error || scheduleCampaign.error) && (
|
||||
<Alert severity="error" sx={{ mt: 3 }}>
|
||||
{sendCampaign.error?.message || scheduleCampaign.error?.message || 'An error occurred'}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<Dialog
|
||||
open={previewDialogOpen}
|
||||
onClose={() => setPreviewDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Campaign Preview</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Subject:</Typography>
|
||||
<Typography variant="body1">{campaign.subject}</Typography>
|
||||
|
||||
{campaign.preheader && (
|
||||
<>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>Preheader:</Typography>
|
||||
<Typography variant="body1">{campaign.preheader}</Typography>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>Email Content:</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
p: 2,
|
||||
maxHeight: '60vh',
|
||||
overflow: 'auto',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
<Box dangerouslySetInnerHTML={{ __html: campaign.content }} />
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampaignSendPage;
|
||||
469
frontend/src/pages/Admin/EmailCampaignEditorPage.jsx
Normal file
469
frontend/src/pages/Admin/EmailCampaignEditorPage.jsx
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Grid,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormHelperText,
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack as ArrowBackIcon,
|
||||
Send as SendIcon,
|
||||
Save as SaveIcon,
|
||||
Visibility as PreviewIcon,
|
||||
PersonAdd as PersonAddIcon,
|
||||
People as PeopleIcon,
|
||||
Tune as TuneIcon
|
||||
} from '@mui/icons-material';
|
||||
import EmailEditor from 'react-email-editor';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useCreateEmailCampaign, useUpdateEmailCampaign, useEmailCampaign, useMailingLists } from '@hooks/emailCampaignHooks';
|
||||
|
||||
const EmailCampaignEditor = () => {
|
||||
const { id } = useParams();
|
||||
const isNewCampaign = !id;
|
||||
const navigate = useNavigate();
|
||||
const emailEditorRef = useRef(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
subject: '',
|
||||
preheader: '',
|
||||
fromName: '',
|
||||
fromEmail: '',
|
||||
content: '',
|
||||
design: null,
|
||||
listIds: [],
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState('');
|
||||
const [segmentDialogOpen, setSegmentDialogOpen] = useState(false);
|
||||
|
||||
// Fetch email campaign data if editing existing campaign
|
||||
const { data: campaign, isLoading: campaignLoading, error: campaignError } = useEmailCampaign(
|
||||
isNewCampaign ? null : id
|
||||
);
|
||||
|
||||
// Fetch available mailing lists
|
||||
const { data: mailingLists, isLoading: listsLoading } = useMailingLists();
|
||||
|
||||
// Mutation hooks for creating and updating campaigns
|
||||
const createCampaign = useCreateEmailCampaign();
|
||||
const updateCampaign = useUpdateEmailCampaign();
|
||||
|
||||
// Load campaign data into form when available
|
||||
useEffect(() => {
|
||||
if (campaign && !isNewCampaign) {
|
||||
setFormData({
|
||||
name: campaign.name || '',
|
||||
subject: campaign.subject || '',
|
||||
preheader: campaign.preheader || '',
|
||||
fromName: campaign.from_name || '',
|
||||
fromEmail: campaign.from_email || '',
|
||||
content: campaign.content || '',
|
||||
design: campaign.design || null,
|
||||
listIds: campaign.list_ids || [],
|
||||
status: campaign.status || 'draft',
|
||||
});
|
||||
|
||||
// Load the design into the editor if it exists
|
||||
if (campaign.design && emailEditorRef.current) {
|
||||
setTimeout(() => {
|
||||
emailEditorRef.current.editor.loadDesign(JSON.parse(campaign.design));
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}, [campaign, isNewCampaign]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleListChange = (event) => {
|
||||
setFormData(prev => ({ ...prev, listIds: event.target.value }));
|
||||
};
|
||||
|
||||
const onEditorReady = () => {
|
||||
// If there's a design being edited, load it
|
||||
if (formData.design && emailEditorRef.current) {
|
||||
try {
|
||||
emailEditorRef.current.editor.loadDesign(
|
||||
typeof formData.design === 'string' ? JSON.parse(formData.design) : formData.design
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading design:", error);
|
||||
}
|
||||
}
|
||||
// Otherwise load a default template
|
||||
else if (emailEditorRef.current) {
|
||||
emailEditorRef.current.editor.loadDesign({
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
values: {
|
||||
containerPadding: "20px",
|
||||
textAlign: "center",
|
||||
text: `<h1>Your Email Campaign</h1>
|
||||
<p>Start editing this template to create your campaign.</p>`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
if (emailEditorRef.current) {
|
||||
emailEditorRef.current.editor.exportHtml((data) => {
|
||||
const { html } = data;
|
||||
setPreviewContent(`
|
||||
<div style="max-width:600px;margin:0 auto;border:1px solid #ccc">
|
||||
<div style="padding:10px;background:#f5f5f5;font-weight:bold">Subject: ${formData.subject}</div>
|
||||
${html}
|
||||
</div>
|
||||
`);
|
||||
setPreviewDialogOpen(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (saveAndExit = false) => {
|
||||
if (!emailEditorRef.current) return;
|
||||
|
||||
try {
|
||||
// Extract the design and HTML content from the editor
|
||||
await emailEditorRef.current.editor.exportHtml(async (data) => {
|
||||
const { design, html } = data;
|
||||
|
||||
// Prepare campaign data
|
||||
const campaignData = {
|
||||
...formData,
|
||||
content: html,
|
||||
design: JSON.stringify(design)
|
||||
};
|
||||
if (isNewCampaign) {
|
||||
// Create new campaign
|
||||
await createCampaign.mutateAsync(campaignData);
|
||||
if (saveAndExit) {
|
||||
navigate('/admin/email-campaigns');
|
||||
}else {
|
||||
navigate(`/admin/email-campaigns/${id || 'new'}/send`);
|
||||
}
|
||||
} else {
|
||||
// Update existing campaign
|
||||
await updateCampaign.mutateAsync({ id, campaignData });
|
||||
if (saveAndExit) {
|
||||
navigate('/admin/email-campaigns');
|
||||
}else {
|
||||
navigate(`/admin/email-campaigns/${id || 'new'}/send`);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save campaign:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCampaign = () => {
|
||||
handleSave(false)
|
||||
}
|
||||
|
||||
if (campaignLoading && !isNewCampaign) {
|
||||
return <Box textAlign="center" py={5}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (campaignError && !isNewCampaign) {
|
||||
return <Alert severity="error">Error loading campaign: {campaignError.message}</Alert>;
|
||||
}
|
||||
|
||||
// Calculate selected lists for display
|
||||
const selectedLists = mailingLists
|
||||
? mailingLists.filter(list => formData.listIds.includes(list.id))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with back button */}
|
||||
<Box mb={3} display="flex" alignItems="center">
|
||||
<IconButton onClick={() => navigate('/admin/email-campaigns')} sx={{ mr: 1 }}>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h4">
|
||||
{isNewCampaign ? 'Create New Campaign' : 'Edit Campaign'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Main campaign editor form */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Campaign Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
helperText="Internal name for this campaign (not shown to recipients)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="status-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-label"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleInputChange}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="draft">Draft</MenuItem>
|
||||
<MenuItem value="scheduled">Scheduled</MenuItem>
|
||||
<MenuItem value="sent">Sent</MenuItem>
|
||||
<MenuItem value="archived">Archived</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="From Name"
|
||||
name="fromName"
|
||||
value={formData.fromName}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
helperText="Name displayed in the 'From' field"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="From Email"
|
||||
name="fromEmail"
|
||||
value={formData.fromEmail}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
type="email"
|
||||
helperText="Email address displayed in the 'From' field"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Subject Line"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
helperText="Compelling subject lines often have higher open rates"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Preheader Text"
|
||||
name="preheader"
|
||||
value={formData.preheader}
|
||||
onChange={handleInputChange}
|
||||
helperText="Text shown in email clients after the subject line (preview text)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box mb={1} display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle1">Target Audience</Typography>
|
||||
<Button
|
||||
startIcon={<TuneIcon />}
|
||||
onClick={() => setSegmentDialogOpen(true)}
|
||||
size="small"
|
||||
>
|
||||
Configure Segments
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="lists-label">Mailing Lists</InputLabel>
|
||||
<Select
|
||||
labelId="lists-label"
|
||||
multiple
|
||||
value={formData.listIds}
|
||||
onChange={handleListChange}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selectedLists.map((list) => (
|
||||
<Chip key={list.id} label={`${list.name} (${list.subscriber_count || 0})`} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{listsLoading ? (
|
||||
<MenuItem disabled>Loading lists...</MenuItem>
|
||||
) : (
|
||||
mailingLists?.map((list) => (
|
||||
<MenuItem key={list.id} value={list.id}>
|
||||
{list.name} ({list.subscriber_count || 0} subscribers)
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Select one or more mailing lists to send this campaign to
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Email Content Editor */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Email Content
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="medium">Tips for creating effective email campaigns:</Typography>
|
||||
<ol>
|
||||
<li>Keep your message clear and concise</li>
|
||||
<li>Include a strong call-to-action</li>
|
||||
<li>Test your email on different devices before sending</li>
|
||||
<li>Personalize content where possible using variables like {`{{first_name}}`}</li>
|
||||
<li>Add alt text to images for better accessibility</li>
|
||||
</ol>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, height: '600px', mb: 3 }}>
|
||||
<EmailEditor
|
||||
ref={emailEditorRef}
|
||||
onReady={onEditorReady}
|
||||
minHeight="600px"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<PreviewIcon />}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/admin/email-campaigns')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={() => handleSave(true)}
|
||||
sx={{ mr: 2 }}
|
||||
disabled={createCampaign.isLoading || updateCampaign.isLoading}
|
||||
>
|
||||
{createCampaign.isLoading || updateCampaign.isLoading ?
|
||||
<CircularProgress size={24} /> : 'Save & Exit'}
|
||||
</Button>
|
||||
|
||||
{!isNewCampaign && <Button
|
||||
variant="contained"
|
||||
startIcon={<SendIcon />}
|
||||
onClick={handleSendCampaign}
|
||||
disabled={formData.listIds.length === 0 || !formData.subject}
|
||||
color="primary"
|
||||
>
|
||||
{'Next: Review & Send'}
|
||||
</Button>}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<Dialog
|
||||
open={previewDialogOpen}
|
||||
onClose={() => setPreviewDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Email Preview
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
This is how your email will appear to recipients
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box dangerouslySetInnerHTML={{ __html: previewContent }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Segment Configuration Dialog */}
|
||||
<Dialog
|
||||
open={segmentDialogOpen}
|
||||
onClose={() => setSegmentDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Configure Audience Segments</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography paragraph>
|
||||
Segmentation allows you to target specific groups within your selected mailing lists.
|
||||
Define conditions below to narrow down your audience.
|
||||
</Typography>
|
||||
|
||||
{/* Segmentation logic would go here */}
|
||||
<Alert severity="info">
|
||||
Advanced segmentation features will be implemented in a future update.
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSegmentDialogOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCampaignEditor;
|
||||
399
frontend/src/pages/Admin/EmailCampaignsPage.jsx
Normal file
399
frontend/src/pages/Admin/EmailCampaignsPage.jsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Chip,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Tabs,
|
||||
Tab,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
ContentCopy as DuplicateIcon,
|
||||
Archive as ArchiveIcon,
|
||||
Send as SendIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Search as SearchIcon,
|
||||
BarChart as AnalyticsIcon,
|
||||
Clear as ClearIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
useEmailCampaigns,
|
||||
useDeleteEmailCampaign,
|
||||
useDuplicateEmailCampaign
|
||||
} from '../../hooks/emailCampaignHooks';
|
||||
|
||||
const EmailCampaignsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [selectedCampaign, setSelectedCampaign] = useState(null);
|
||||
|
||||
// Get all campaigns with optional filtering
|
||||
const { data: campaigns, isLoading, error, refetch } = useEmailCampaigns(
|
||||
activeTab === 0 ? undefined :
|
||||
activeTab === 1 ? 'draft' :
|
||||
activeTab === 2 ? 'scheduled' :
|
||||
activeTab === 3 ? 'sent' :
|
||||
activeTab === 4 ? 'archived' : undefined
|
||||
);
|
||||
|
||||
// Delete campaign mutation
|
||||
const deleteCampaign = useDeleteEmailCampaign();
|
||||
|
||||
// Duplicate campaign mutation
|
||||
const duplicateCampaign = useDuplicateEmailCampaign();
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Handle menu open
|
||||
const handleMenuOpen = (event, campaign) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedCampaign(campaign);
|
||||
};
|
||||
|
||||
// Handle menu close
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedCampaign(null);
|
||||
};
|
||||
|
||||
// Handle campaign deletion
|
||||
const handleDelete = async () => {
|
||||
if (selectedCampaign) {
|
||||
try {
|
||||
await deleteCampaign.mutateAsync(selectedCampaign.id);
|
||||
handleMenuClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete campaign:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle campaign duplication
|
||||
const handleDuplicate = async () => {
|
||||
if (selectedCampaign) {
|
||||
try {
|
||||
await duplicateCampaign.mutateAsync(selectedCampaign.id);
|
||||
handleMenuClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate campaign:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Filter campaigns based on search term
|
||||
const filteredCampaigns = campaigns
|
||||
? campaigns.filter(campaign =>
|
||||
campaign.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
campaign.subject.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
: [];
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||
};
|
||||
|
||||
// Get status chip color
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'default';
|
||||
case 'scheduled':
|
||||
return 'warning';
|
||||
case 'sending':
|
||||
return 'info';
|
||||
case 'sent':
|
||||
return 'success';
|
||||
case 'archived':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Get status display text
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return 'Draft';
|
||||
case 'scheduled':
|
||||
return 'Scheduled';
|
||||
case 'sending':
|
||||
return 'Sending';
|
||||
case 'sent':
|
||||
return 'Sent';
|
||||
case 'archived':
|
||||
return 'Archived';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return <Alert severity="error" sx={{ my: 2 }}>{error.message}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Email Campaigns
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to="/admin/email-campaigns/new"
|
||||
>
|
||||
Create Campaign
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Tabs for filtering */}
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label="All Campaigns" />
|
||||
<Tab label="Drafts" />
|
||||
<Tab label="Scheduled" />
|
||||
<Tab label="Sent" />
|
||||
<Tab label="Archived" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
|
||||
{/* Search and Actions */}
|
||||
<Box sx={{ display: 'flex', mb: 3, gap: 2 }}>
|
||||
<TextField
|
||||
placeholder="Search campaigns..."
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
size="small"
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="clear search"
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table aria-label="email campaigns table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Subject</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Recipients</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Sent/Scheduled</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredCampaigns.length > 0 ? (
|
||||
filteredCampaigns.map((campaign) => (
|
||||
<TableRow key={campaign.id}>
|
||||
<TableCell>{campaign.name}</TableCell>
|
||||
<TableCell>{campaign.subject}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getStatusText(campaign.status)}
|
||||
color={getStatusColor(campaign.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{campaign.recipient_count === undefined ? '—' : campaign.recipient_count}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(campaign.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
{campaign.status === 'sent'
|
||||
? formatDate(campaign.sent_at)
|
||||
: campaign.status === 'scheduled'
|
||||
? formatDate(campaign.scheduled_for)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{['draft', 'scheduled'].includes(campaign.status) && (
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
aria-label="edit campaign"
|
||||
onClick={() => navigate(`/admin/email-campaigns/${campaign.id}`)}
|
||||
size="small"
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{campaign.status === 'draft' && (
|
||||
<Tooltip title="Send">
|
||||
<IconButton
|
||||
aria-label="send campaign"
|
||||
onClick={() => navigate(`/admin/email-campaigns/${campaign.id}/send`)}
|
||||
size="small"
|
||||
color="primary"
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{['sent', 'scheduled'].includes(campaign.status) && (
|
||||
<Tooltip title="View Analytics">
|
||||
<IconButton
|
||||
aria-label="campaign analytics"
|
||||
onClick={() => navigate(`/admin/email-campaigns/${campaign.id}/analytics`)}
|
||||
size="small"
|
||||
color="info"
|
||||
>
|
||||
<AnalyticsIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
aria-label="more options"
|
||||
onClick={(e) => handleMenuOpen(e, campaign)}
|
||||
size="small"
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body1" py={3}>
|
||||
{searchTerm
|
||||
? 'No campaigns match your search criteria.'
|
||||
: activeTab !== 0
|
||||
? `No ${activeTab === 1 ? 'draft' : activeTab === 2 ? 'scheduled' : activeTab === 3 ? 'sent' : 'archived'} campaigns found.`
|
||||
: 'No campaigns found. Create your first campaign by clicking the "Create Campaign" button.'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Campaign Actions Menu */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={handleDuplicate} disabled={duplicateCampaign.isLoading}>
|
||||
<ListItemIcon>
|
||||
<DuplicateIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Duplicate</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
{selectedCampaign && selectedCampaign.status !== 'archived' && (
|
||||
<MenuItem onClick={() => {
|
||||
// Archive logic would go here
|
||||
handleMenuClose();
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
<ArchiveIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Archive</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={handleDelete}
|
||||
disabled={deleteCampaign.isLoading}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: 'error.main' }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailCampaignsPage;
|
||||
|
|
@ -184,7 +184,6 @@ const DEFAULT_TEMPLATES = {
|
|||
}
|
||||
},
|
||||
shipping_notification: {
|
||||
// Simplified template - the actual structure would be more complex in the real editor
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
|
|
|
|||
412
frontend/src/pages/Admin/MailingListsPage.jsx
Normal file
412
frontend/src/pages/Admin/MailingListsPage.jsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Tabs,
|
||||
Tab,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Grid,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Download as DownloadIcon,
|
||||
Upload as UploadIcon,
|
||||
People as PeopleIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
useMailingLists,
|
||||
useCreateMailingList,
|
||||
useUpdateMailingList,
|
||||
useDeleteMailingList,
|
||||
useImportSubscribers,
|
||||
useExportSubscribers
|
||||
} 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
|
||||
const { data: mailingLists, isLoading, error } = useMailingLists();
|
||||
|
||||
// Mutations
|
||||
const createList = useCreateMailingList();
|
||||
const updateList = useUpdateMailingList();
|
||||
const deleteList = useDeleteMailingList();
|
||||
const importSubscribers = useImportSubscribers();
|
||||
const exportSubscribers = useExportSubscribers();
|
||||
|
||||
// New list form state
|
||||
const [newListForm, setNewListForm] = useState({
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
// Handle form input changes
|
||||
const handleFormChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewListForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle edit form input changes
|
||||
const handleEditFormChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setEditListData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Filter lists based on search term
|
||||
const filteredLists = mailingLists
|
||||
? mailingLists.filter(list =>
|
||||
list.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(list.description && list.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
)
|
||||
: [];
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
};
|
||||
|
||||
// Open menu for a specific list
|
||||
const handleMenuOpen = (event, listId) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedListId(listId);
|
||||
};
|
||||
|
||||
// Close menu
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
setSelectedListId(null);
|
||||
};
|
||||
|
||||
// Open edit dialog for a list
|
||||
const handleEditList = (list) => {
|
||||
setEditListData(list);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// Open delete confirmation dialog
|
||||
const handleDeleteClick = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// Confirm list deletion
|
||||
const handleConfirmDelete = async () => {
|
||||
if (selectedListId) {
|
||||
try {
|
||||
await deleteList.mutateAsync(selectedListId);
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedListId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete list:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle new list submission
|
||||
const handleCreateList = async () => {
|
||||
if (!newListForm.name) return;
|
||||
|
||||
try {
|
||||
await createList.mutateAsync(newListForm);
|
||||
setNewListForm({ name: '', description: '' });
|
||||
setNewListDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create list:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle list update
|
||||
const handleUpdateList = async () => {
|
||||
if (!editListData || !editListData.id || !editListData.name) return;
|
||||
|
||||
try {
|
||||
await updateList.mutateAsync({
|
||||
id: editListData.id,
|
||||
listData: {
|
||||
name: editListData.name,
|
||||
description: editListData.description
|
||||
}
|
||||
});
|
||||
setEditListData(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update list:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 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) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return <Alert severity="error" sx={{ my: 2 }}>{error.message}</Alert>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Mailing Lists
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder="Search mailing lists"
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<IconButton onClick={handleClearSearch}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} textAlign="right">
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setNewListDialogOpen(true)}
|
||||
>
|
||||
New List
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredLists.map((list) => (
|
||||
<TableRow key={list.id}>
|
||||
<TableCell>{list.name}</TableCell>
|
||||
<TableCell>{list.description || '—'}</TableCell>
|
||||
<TableCell>{formatDate(list.created_at)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton onClick={(e) => handleMenuOpen(e, list.id)}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={selectedListId === list.id}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={() => handleEditList(list)}>
|
||||
<ListItemIcon><EditIcon fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Edit</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDeleteClick}>
|
||||
<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>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* New List Dialog */}
|
||||
<Dialog open={newListDialogOpen} onClose={() => setNewListDialogOpen(false)}>
|
||||
<DialogTitle>Create Mailing List</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
name="name"
|
||||
value={newListForm.name}
|
||||
onChange={handleFormChange}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
name="description"
|
||||
value={newListForm.description}
|
||||
onChange={handleFormChange}
|
||||
margin="dense"
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setNewListDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreateList}>Create</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit List Dialog */}
|
||||
<Dialog open={!!editListData} onClose={() => setEditListData(null)}>
|
||||
<DialogTitle>Edit Mailing List</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
name="name"
|
||||
value={editListData?.name || ''}
|
||||
onChange={handleEditFormChange}
|
||||
margin="dense"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
name="description"
|
||||
value={editListData?.description || ''}
|
||||
onChange={handleEditFormChange}
|
||||
margin="dense"
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditListData(null)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleUpdateList}>Save</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete this mailing list?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default MailingListsPage;
|
||||
775
frontend/src/pages/Admin/SubscribersPage.jsx
Normal file
775
frontend/src/pages/Admin/SubscribersPage.jsx
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
Breadcrumbs,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
ArrowBack as ArrowBackIcon,
|
||||
MoreVert as MoreVertIcon,
|
||||
Mail as MailIcon,
|
||||
Search as SearchIcon,
|
||||
Clear as ClearIcon,
|
||||
Block as BlockIcon,
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
useMailingList,
|
||||
useSubscribers,
|
||||
useAddSubscriber,
|
||||
useUpdateSubscriber,
|
||||
useDeleteSubscriber,
|
||||
useSubscriberActivity
|
||||
} from '@hooks/emailCampaignHooks';
|
||||
|
||||
const SubscribersPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { listId } = useParams();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25);
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedSubscriber, setSelectedSubscriber] = useState(null);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [activityDialogOpen, setActivityDialogOpen] = useState(false);
|
||||
// New subscriber form data
|
||||
const [subscriberForm, setSubscriberForm] = useState({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Fetch mailing list details
|
||||
const { data: mailingList, isLoading: listLoading, error: listError } = useMailingList(listId);
|
||||
|
||||
// Fetch subscribers with pagination and filtering
|
||||
const { data: subscribersData, isLoading: subscribersLoading, error: subscribersError } =
|
||||
useSubscribers(listId, page, rowsPerPage, searchTerm, statusFilter);
|
||||
|
||||
// Fetch subscriber activity when needed
|
||||
const { data: subscriberActivity, isLoading: activityLoading } =
|
||||
useSubscriberActivity(selectedSubscriber?.id, activityDialogOpen);
|
||||
|
||||
// Mutations
|
||||
const addSubscriber = useAddSubscriber();
|
||||
const updateSubscriber = useUpdateSubscriber();
|
||||
const deleteSubscriber = useDeleteSubscriber();
|
||||
|
||||
// Handle form input changes
|
||||
const handleFormChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setSubscriberForm(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(0); // Reset to first page when searching
|
||||
};
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = () => {
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// Handle status filter change
|
||||
const handleStatusFilterChange = (e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(0); // Reset to first page when changing filter
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handleChangePage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Handle rows per page change
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// Open menu for a specific subscriber
|
||||
const handleMenuOpen = (event, subscriber) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setSelectedSubscriber(subscriber);
|
||||
};
|
||||
|
||||
// Close menu
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
// Open edit dialog for a subscriber
|
||||
const handleEditSubscriber = () => {
|
||||
if (!selectedSubscriber) return;
|
||||
|
||||
setSubscriberForm({
|
||||
email: selectedSubscriber.email,
|
||||
firstName: selectedSubscriber.first_name || '',
|
||||
lastName: selectedSubscriber.last_name || '',
|
||||
status: selectedSubscriber.status
|
||||
});
|
||||
|
||||
setEditDialogOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// Open delete confirmation dialog
|
||||
const handleDeleteClick = () => {
|
||||
setDeleteDialogOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// View subscriber activity
|
||||
const handleViewActivity = () => {
|
||||
setActivityDialogOpen(true);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
// Add new subscriber
|
||||
const handleAddSubscriber = async () => {
|
||||
if (!subscriberForm.email) return;
|
||||
|
||||
try {
|
||||
await addSubscriber.mutateAsync({
|
||||
listId,
|
||||
subscriberData: {
|
||||
email: subscriberForm.email,
|
||||
firstName: subscriberForm.firstName,
|
||||
lastName: subscriberForm.lastName,
|
||||
status: subscriberForm.status
|
||||
}
|
||||
});
|
||||
|
||||
// Reset form and close dialog
|
||||
setSubscriberForm({
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
status: 'active'
|
||||
});
|
||||
setAddDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to add subscriber:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Update subscriber
|
||||
const handleUpdateSubscriber = async () => {
|
||||
if (!selectedSubscriber || !subscriberForm.email) return;
|
||||
|
||||
try {
|
||||
await updateSubscriber.mutateAsync({
|
||||
id: selectedSubscriber.id,
|
||||
subscriberData: {
|
||||
email: subscriberForm.email,
|
||||
firstName: subscriberForm.firstName,
|
||||
lastName: subscriberForm.lastName,
|
||||
status: subscriberForm.status
|
||||
}
|
||||
});
|
||||
|
||||
// Reset form and close dialog
|
||||
setEditDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscriber:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete subscriber
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedSubscriber) return;
|
||||
|
||||
try {
|
||||
await deleteSubscriber.mutateAsync(selectedSubscriber.id);
|
||||
setDeleteDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete subscriber:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
|
||||
};
|
||||
|
||||
// Get status chip color
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'unsubscribed':
|
||||
return 'error';
|
||||
case 'bounced':
|
||||
return 'warning';
|
||||
case 'complained':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (listLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (listError) {
|
||||
return <Alert severity="error" sx={{ my: 2 }}>{listError.message}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with breadcrumbs */}
|
||||
<Box mb={3}>
|
||||
<Button
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin/mailing-lists')}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
Back to Mailing Lists
|
||||
</Button>
|
||||
|
||||
<Breadcrumbs sx={{ mb: 2 }}>
|
||||
<Link component={RouterLink} to="/admin" color="inherit">
|
||||
Admin
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/admin/mailing-lists" color="inherit">
|
||||
Mailing Lists
|
||||
</Link>
|
||||
<Typography color="text.primary">Subscribers</Typography>
|
||||
</Breadcrumbs>
|
||||
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="h4" component="h1">
|
||||
{mailingList?.name} Subscribers
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
Add Subscriber
|
||||
</Button>
|
||||
</Box>
|
||||
{mailingList?.description && (
|
||||
<Typography variant="body1" color="text.secondary" mt={1}>
|
||||
{mailingList.description}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Paper sx={{ p: 2, mb: 3 }}>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search by email or name..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchTerm && (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleClearSearch}
|
||||
aria-label="clear search"
|
||||
>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="status-filter-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-filter-label"
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={handleStatusFilterChange}
|
||||
>
|
||||
<MenuItem value="all">All Statuses</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="unsubscribed">Unsubscribed</MenuItem>
|
||||
<MenuItem value="bounced">Bounced</MenuItem>
|
||||
<MenuItem value="complained">Complained</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs md={5}>
|
||||
<Box display="flex" justifyContent="flex-end" alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary" mr={2}>
|
||||
{subscribersData?.totalCount || 0} total subscribers
|
||||
{statusFilter !== 'all' && subscribersData?.filteredCount !== undefined && (
|
||||
<>, {subscribersData.filteredCount} ${statusFilter}</>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Subscribers Table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Subscribed</TableCell>
|
||||
<TableCell>Last Activity</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{subscribersLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<CircularProgress size={40} sx={{ my: 3 }} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : subscribersData?.subscribers?.length > 0 ? (
|
||||
subscribersData.subscribers.map((subscriber) => (
|
||||
<TableRow key={subscriber.id}>
|
||||
<TableCell>{subscriber.email}</TableCell>
|
||||
<TableCell>
|
||||
{subscriber.first_name || subscriber.last_name
|
||||
? `${subscriber.first_name || ''} ${subscriber.last_name || ''}`.trim()
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={subscriber.status}
|
||||
size="small"
|
||||
color={getStatusColor(subscriber.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(subscriber.subscribed_at)}</TableCell>
|
||||
<TableCell>
|
||||
{subscriber.last_activity_at
|
||||
? formatDate(subscriber.last_activity_at)
|
||||
: 'No activity'}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setSelectedSubscriber(subscriber);
|
||||
handleEditSubscriber();
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton
|
||||
onClick={(e) => handleMenuOpen(e, subscriber)}
|
||||
size="small"
|
||||
>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<Typography variant="body1" py={3}>
|
||||
{searchTerm || statusFilter !== 'all'
|
||||
? 'No subscribers match your filters.'
|
||||
: 'No subscribers in this list yet. Add your first subscriber to get started.'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Pagination */}
|
||||
{subscribersData && subscribersData.totalCount > 0 && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={subscribersData.filteredCount || subscribersData.totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subscriber Actions Menu */}
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<MenuItem onClick={handleViewActivity}>
|
||||
<ListItemIcon>
|
||||
<MailIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>View Activity</ListItemText>
|
||||
</MenuItem>
|
||||
|
||||
{selectedSubscriber && selectedSubscriber.status === 'active' && (
|
||||
<MenuItem onClick={() => {
|
||||
setSubscriberForm({
|
||||
...subscriberForm,
|
||||
email: selectedSubscriber.email,
|
||||
firstName: selectedSubscriber.first_name || '',
|
||||
lastName: selectedSubscriber.last_name || '',
|
||||
status: 'unsubscribed'
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
handleMenuClose();
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
<BlockIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Unsubscribe</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{selectedSubscriber && selectedSubscriber.status !== 'active' && (
|
||||
<MenuItem onClick={() => {
|
||||
setSubscriberForm({
|
||||
...subscriberForm,
|
||||
email: selectedSubscriber.email,
|
||||
firstName: selectedSubscriber.first_name || '',
|
||||
lastName: selectedSubscriber.last_name || '',
|
||||
status: 'active'
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
handleMenuClose();
|
||||
}}>
|
||||
<ListItemIcon>
|
||||
<CheckCircleIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Reactivate</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
onClick={handleDeleteClick}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<ListItemIcon sx={{ color: 'error.main' }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
{/* Add Subscriber Dialog */}
|
||||
<Dialog
|
||||
open={addDialogOpen}
|
||||
onClose={() => setAddDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Add New Subscriber</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={subscriberForm.email}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={subscriberForm.firstName}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={subscriberForm.lastName}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel id="status-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="status-label"
|
||||
name="status"
|
||||
value={subscriberForm.status}
|
||||
label="Status"
|
||||
onChange={handleFormChange}
|
||||
>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="unsubscribed">Unsubscribed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{addSubscriber.error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{addSubscriber.error.message}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAddDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAddSubscriber}
|
||||
variant="contained"
|
||||
disabled={!subscriberForm.email || addSubscriber.isLoading}
|
||||
>
|
||||
{addSubscriber.isLoading ? <CircularProgress size={24} /> : 'Add Subscriber'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Subscriber Dialog */}
|
||||
<Dialog
|
||||
open={editDialogOpen}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Edit Subscriber</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
autoFocus
|
||||
margin="dense"
|
||||
name="email"
|
||||
label="Email Address"
|
||||
type="email"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={subscriberForm.email}
|
||||
onChange={handleFormChange}
|
||||
required
|
||||
sx={{ mb: 2, mt: 1 }}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
name="firstName"
|
||||
label="First Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={subscriberForm.firstName}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField
|
||||
margin="dense"
|
||||
name="lastName"
|
||||
label="Last Name"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={subscriberForm.lastName}
|
||||
onChange={handleFormChange}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel id="edit-status-label">Status</InputLabel>
|
||||
<Select
|
||||
labelId="edit-status-label"
|
||||
name="status"
|
||||
value={subscriberForm.status}
|
||||
label="Status"
|
||||
onChange={handleFormChange}
|
||||
>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="unsubscribed">Unsubscribed</MenuItem>
|
||||
<MenuItem value="bounced">Bounced</MenuItem>
|
||||
<MenuItem value="complained">Complained</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{updateSubscriber.error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{updateSubscriber.error.message}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setEditDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleUpdateSubscriber}
|
||||
variant="contained"
|
||||
disabled={!subscriberForm.email || updateSubscriber.isLoading}
|
||||
>
|
||||
{updateSubscriber.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete the subscriber{' '}
|
||||
<strong>{selectedSubscriber?.email}</strong>?
|
||||
This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleConfirmDelete}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteSubscriber.isLoading}
|
||||
>
|
||||
{deleteSubscriber.isLoading ? <CircularProgress size={24} /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Activity Dialog */}
|
||||
<Dialog
|
||||
open={activityDialogOpen}
|
||||
onClose={() => setActivityDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Subscriber Activity
|
||||
{selectedSubscriber && (
|
||||
<Typography variant="subtitle1" component="div">
|
||||
{selectedSubscriber.email}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{activityLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : subscriberActivity && subscriberActivity.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Campaign</TableCell>
|
||||
<TableCell>Activity Type</TableCell>
|
||||
<TableCell>Details</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{subscriberActivity.map((activity) => (
|
||||
<TableRow key={activity.id}>
|
||||
<TableCell>{formatDate(activity.timestamp)}</TableCell>
|
||||
<TableCell>{activity.campaign_name || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={activity.type}
|
||||
size="small"
|
||||
color={
|
||||
activity.type === 'open' ? 'info' :
|
||||
activity.type === 'click' ? 'success' :
|
||||
activity.type === 'bounce' ? 'warning' :
|
||||
activity.type === 'complaint' ? 'error' :
|
||||
activity.type === 'unsubscribe' ? 'error' :
|
||||
'default'
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{activity.details || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography align="center" py={3}>
|
||||
No activity recorded for this subscriber.
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setActivityDialogOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscribersPage;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, CircularProgress, Alert } from '@mui/material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useRequestLoginCode, useVerifyCode } from '../hooks/apiHooks';
|
||||
|
||||
const LoginPage = () => {
|
||||
|
|
@ -165,6 +165,13 @@ const LoginPage = () => {
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" align="center">
|
||||
Don't have an account?{' '}
|
||||
<RouterLink to="/auth/register">
|
||||
Sign up
|
||||
</RouterLink>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, TextField, Button, Typography, CircularProgress, Alert, Grid } from '@mui/material';
|
||||
import { Box, TextField, Button, Typography, CircularProgress, Alert, Grid, FormControlLabel, Checkbox, } from '@mui/material';
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useRegister } from '../hooks/apiHooks';
|
||||
|
||||
|
|
@ -9,6 +9,7 @@ const RegisterPage = () => {
|
|||
lastName: '',
|
||||
email: '',
|
||||
});
|
||||
const [mailingListEnabled, setMailingListEnabled] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// React Query mutation
|
||||
|
|
@ -21,6 +22,10 @@ const RegisterPage = () => {
|
|||
[name]: value,
|
||||
}));
|
||||
};
|
||||
// Handle Mailing List checkbox change
|
||||
const handleMailingListToggle = (event) => {
|
||||
setMailingListEnabled(event.target.checked);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -31,7 +36,7 @@ const RegisterPage = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
await register.mutateAsync(formData);
|
||||
await register.mutateAsync({...formData, isSubscribed: mailingListEnabled});
|
||||
|
||||
// Redirect to login page after successful registration
|
||||
setTimeout(() => {
|
||||
|
|
@ -105,6 +110,20 @@ const RegisterPage = () => {
|
|||
disabled={register.isLoading || register.isSuccess}
|
||||
/>
|
||||
|
||||
<Box align="center">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={mailingListEnabled}
|
||||
onChange={handleMailingListToggle}
|
||||
name="mailingListEnabled"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Signup for our mailing list?"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
|
|
@ -119,6 +138,7 @@ const RegisterPage = () => {
|
|||
'Register'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<Typography variant="body2" align="center">
|
||||
Already have an account?{' '}
|
||||
|
|
|
|||
117
frontend/src/pages/SubscriptionConfirmPage.jsx
Normal file
117
frontend/src/pages/SubscriptionConfirmPage.jsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import apiClient from '@services/api';
|
||||
|
||||
const SubscriptionConfirmPage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const confirmSubscription = async () => {
|
||||
try {
|
||||
// Get token from URL query params
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
|
||||
if (!token) {
|
||||
setError('Missing confirmation token. Please check your email and click the link again.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the API to confirm the subscription
|
||||
const response = await apiClient.get(`/api/subscribers/confirm?token=${token}`);
|
||||
|
||||
setConfirmed(true);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
error.response?.data?.message ||
|
||||
'An error occurred while confirming your subscription. The link may be invalid or expired.'
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
confirmSubscription();
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '70vh'
|
||||
}}>
|
||||
<Card sx={{ width: '100%', mb: 4 }}>
|
||||
<CardContent sx={{ textAlign: 'center', p: 4 }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} sx={{ mb: 3 }} />
|
||||
<Typography variant="h6">
|
||||
Confirming your subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : confirmed ? (
|
||||
<Box>
|
||||
<CheckCircleIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Subscription Confirmed!
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Thank you for confirming your subscription. You're now signed up to receive our newsletters and updates.
|
||||
</Typography>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate('/')}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<ErrorIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Confirmation Failed
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionConfirmPage;
|
||||
319
frontend/src/pages/SubscriptionPreferencesPage.jsx
Normal file
319
frontend/src/pages/SubscriptionPreferencesPage.jsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Grid,
|
||||
Paper,
|
||||
Switch
|
||||
} from '@mui/material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import apiClient from '@services/api';
|
||||
|
||||
const SubscriptionPreferencesPage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [subscriber, setSubscriber] = useState(null);
|
||||
const [lists, setLists] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
listPreferences: {}
|
||||
});
|
||||
|
||||
const { token } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const loadSubscriptionPreferences = async () => {
|
||||
try {
|
||||
if (!token) {
|
||||
setError('Missing preferences token. You need a valid link to access your preferences.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the API to get subscriber info and preferences
|
||||
const response = await apiClient.get(`/api/subscribers/preferences?token=${token}`);
|
||||
|
||||
// Set subscriber info
|
||||
setSubscriber(response.data.subscriber);
|
||||
setLists(response.data.lists);
|
||||
|
||||
// Initialize form data
|
||||
const { email, first_name, last_name } = response.data.subscriber;
|
||||
const listPrefs = {};
|
||||
|
||||
response.data.lists.forEach(list => {
|
||||
listPrefs[list.id] = list.is_subscribed;
|
||||
});
|
||||
|
||||
setFormData({
|
||||
firstName: first_name || '',
|
||||
lastName: last_name || '',
|
||||
email: email,
|
||||
listPreferences: listPrefs
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
error.response?.data?.message ||
|
||||
'An error occurred while loading your preferences. The link may be invalid or expired.'
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSubscriptionPreferences();
|
||||
}, [token]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleListToggle = (listId) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
listPreferences: {
|
||||
...prev.listPreferences,
|
||||
[listId]: !prev.listPreferences[listId]
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUnsubscribeAll = () => {
|
||||
const updatedPrefs = {};
|
||||
Object.keys(formData.listPreferences).forEach(listId => {
|
||||
updatedPrefs[listId] = false;
|
||||
});
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
listPreferences: updatedPrefs
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSavePreferences = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Call API to update preferences
|
||||
await apiClient.post(`/api/subscribers/preferences?token=${token}`, {
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
listPreferences: formData.listPreferences
|
||||
});
|
||||
|
||||
setSaved(true);
|
||||
setSaving(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
error.response?.data?.message ||
|
||||
'An error occurred while saving your preferences. Please try again or contact support.'
|
||||
);
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="md">
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Typography variant="h4" component="h1" gutterBottom align="center" sx={{ mb: 4 }}>
|
||||
Email Subscription Preferences
|
||||
</Typography>
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} sx={{ mb: 3 }} />
|
||||
<Typography variant="h6">
|
||||
Loading your preferences...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', p: 4 }}>
|
||||
<ErrorIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Unable to Load Preferences
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : saved ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', p: 4 }}>
|
||||
<CheckCircleIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Preferences Updated Successfully
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
Your subscription preferences have been saved. Thank you for keeping your information up to date.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : subscriber ? (
|
||||
<Box component="form" onSubmit={handleSavePreferences}>
|
||||
<Grid container spacing={4}>
|
||||
{/* Personal Information */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Paper sx={{ p: 3, height: '100%' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Your Information
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
value={formData.email}
|
||||
disabled
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="First Name"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Last Name"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
margin="normal"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Your information is securely stored and we will never share it with third parties.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Subscription Preferences */}
|
||||
<Grid item xs={12} md={7}>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Email Subscriptions
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
onClick={handleUnsubscribeAll}
|
||||
>
|
||||
Unsubscribe All
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{lists.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
You are not currently subscribed to any mailing lists.
|
||||
</Alert>
|
||||
) : (
|
||||
<List>
|
||||
{lists.map(list => (
|
||||
<ListItem
|
||||
key={list.id}
|
||||
divider
|
||||
secondaryAction={
|
||||
<Switch
|
||||
edge="end"
|
||||
checked={formData.listPreferences[list.id] || false}
|
||||
onChange={() => handleListToggle(list.id)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={list.name}
|
||||
secondary={list.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Toggle the switches to manage your subscriptions. You can update these preferences at any time
|
||||
by requesting a new preferences link to your email.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/')}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? <CircularProgress size={24} /> : 'Save Preferences'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPreferencesPage;
|
||||
274
frontend/src/pages/UnsubscribePage.jsx
Normal file
274
frontend/src/pages/UnsubscribePage.jsx
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Container,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
TextField,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import apiClient from '@services/api';
|
||||
|
||||
const UnsubscribePage = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [unsubscribed, setUnsubscribed] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [customReason, setCustomReason] = useState('');
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const checkUnsubscribeParams = async () => {
|
||||
try {
|
||||
// Get token from URL query params
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
const emailParam = params.get('email');
|
||||
const listId = params.get('listId');
|
||||
|
||||
// If we have a token, use it to unsubscribe automatically
|
||||
if (token) {
|
||||
await handleUnsubscribe(null, token, listId);
|
||||
}
|
||||
// If we just have an email, pre-fill the form
|
||||
else if (emailParam) {
|
||||
setEmail(emailParam);
|
||||
setShowForm(true);
|
||||
setLoading(false);
|
||||
}
|
||||
// No token or email, show the form
|
||||
else {
|
||||
setShowForm(true);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
error.response?.data?.message ||
|
||||
'An error occurred. Please try again or contact support.'
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkUnsubscribeParams();
|
||||
}, [location]);
|
||||
|
||||
const handleUnsubscribe = async (e, tokenOverride = null, listIdOverride = null) => {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
if (!tokenOverride && !email) {
|
||||
setError('Please enter your email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
// Construct parameters
|
||||
const params = new URLSearchParams();
|
||||
if (tokenOverride) params.append('token', tokenOverride);
|
||||
if (!tokenOverride && email) params.append('email', email);
|
||||
if (listIdOverride) params.append('listId', listIdOverride);
|
||||
|
||||
// Call the API to process the unsubscribe request
|
||||
await apiClient.get(`/api/subscribers/unsubscribe?${params.toString()}`);
|
||||
|
||||
// Save feedback if provided
|
||||
if (reason) {
|
||||
try {
|
||||
await apiClient.post('/api/subscribers/feedback', {
|
||||
email,
|
||||
reason: reason === 'other' ? customReason : reason
|
||||
});
|
||||
} catch (feedbackError) {
|
||||
console.error('Error saving feedback:', feedbackError);
|
||||
// We don't want to show an error for feedback submission failure
|
||||
}
|
||||
}
|
||||
|
||||
setUnsubscribed(true);
|
||||
setProcessing(false);
|
||||
} catch (error) {
|
||||
setError(
|
||||
error.response?.data?.message ||
|
||||
'An error occurred while processing your request. Please try again or contact support.'
|
||||
);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="sm">
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '70vh',
|
||||
py: 4
|
||||
}}>
|
||||
<Card sx={{ width: '100%', mb: 4 }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
||||
<CircularProgress size={60} sx={{ mb: 3 }} />
|
||||
<Typography variant="h6">
|
||||
Processing your request...
|
||||
</Typography>
|
||||
</Box>
|
||||
) : unsubscribed ? (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<CheckCircleIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Unsubscribed Successfully
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph>
|
||||
You have been successfully unsubscribed from our mailing list.
|
||||
We're sorry to see you go, but we respect your decision.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
If you change your mind, you can always subscribe again in the future.
|
||||
</Typography>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
</Box>
|
||||
) : showForm ? (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom align="center">
|
||||
Unsubscribe
|
||||
</Typography>
|
||||
<Typography variant="body1" paragraph align="center">
|
||||
We're sorry to see you go. Please enter your email address below to unsubscribe from our mailing list.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleUnsubscribe} sx={{ mt: 3 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Email Address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
margin="normal"
|
||||
required
|
||||
type="email"
|
||||
disabled={processing}
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<FormControl component="fieldset">
|
||||
<FormLabel component="legend">Would you mind telling us why you're unsubscribing? (Optional)</FormLabel>
|
||||
<RadioGroup
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
>
|
||||
<FormControlLabel
|
||||
value="too_many"
|
||||
control={<Radio />}
|
||||
label="I receive too many emails"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="not_relevant"
|
||||
control={<Radio />}
|
||||
label="The content is not relevant to me"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="not_subscribed"
|
||||
control={<Radio />}
|
||||
label="I never subscribed"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value="other"
|
||||
control={<Radio />}
|
||||
label="Other reason"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
{reason === 'other' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Please specify"
|
||||
value={customReason}
|
||||
onChange={(e) => setCustomReason(e.target.value)}
|
||||
margin="normal"
|
||||
disabled={processing}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => navigate('/')}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!email || processing}
|
||||
>
|
||||
{processing ? <CircularProgress size={24} /> : 'Unsubscribe'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<ErrorIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Something Went Wrong
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error || 'Unable to process your request. Please try again or contact support.'}
|
||||
</Alert>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to Home Page
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnsubscribePage;
|
||||
|
|
@ -7,6 +7,7 @@ export const authService = {
|
|||
* @param {string} userData.email - User's email
|
||||
* @param {string} userData.firstName - User's first name
|
||||
* @param {string} userData.lastName - User's last name
|
||||
* @param {string} userData.isSubscribed - User's Mailinglist preference
|
||||
* @returns {Promise} Promise with the API response
|
||||
*/
|
||||
register: async (userData) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue