Mailing list implemntation

This commit is contained in:
2ManyProjects 2025-05-01 21:28:49 -05:00
parent 3960853e61
commit 7de980ba1b
27 changed files with 7980 additions and 30 deletions

View file

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

View file

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

View file

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

View 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]

File diff suppressed because it is too large Load diff

View 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;
};

View 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;
};

View file

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

View file

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

View file

@ -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 */}

View 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;

View 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
};

View file

@ -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' },

View 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;

View 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;

View 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;

View 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;

View file

@ -184,7 +184,6 @@ const DEFAULT_TEMPLATES = {
}
},
shipping_notification: {
// Simplified template - the actual structure would be more complex in the real editor
body: {
rows: [
{

View 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;

View 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;

View file

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

View file

@ -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
@ -120,6 +139,7 @@ const RegisterPage = () => {
)}
</Button>
<Typography variant="body2" align="center">
Already have an account?{' '}
<RouterLink to="/auth/login">

View 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;

View 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;

View 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;

View file

@ -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) => {