Blog Support, Whitelabel support better themeing

This commit is contained in:
2ManyProjects 2025-04-30 19:26:40 -05:00
parent 37da2acb5d
commit f10ed6bf08
43 changed files with 5109 additions and 3475 deletions

2
.gitignore vendored
View file

@ -3,4 +3,4 @@ node_modules
npm-debug.log
yarn-error.log
.DS_Store
public/uploads/*
backend/public/uploads/*

View file

@ -11,9 +11,8 @@ const settingsAdminRoutes = require('./routes/settingsAdmin');
const SystemSettings = require('./models/SystemSettings');
const fs = require('fs');
// services
const notificationService = require('./services/notificationService');
const emailService = require('./services/emailService');
// routes
const stripePaymentRoutes = require('./routes/stripePayment');
@ -32,6 +31,8 @@ const blogAdminRoutes = require('./routes/blogAdmin');
const blogCommentsAdminRoutes = require('./routes/blogCommentsAdmin');
const productReviewsRoutes = require('./routes/productReviews');
const productReviewsAdminRoutes = require('./routes/productReviewsAdmin');
const emailTemplatesAdminRoutes = require('./routes/emailTemplatesAdmin');
const publicSettingsRoutes = require('./routes/publicSettings');
// Create Express app
const app = express();
@ -180,6 +181,8 @@ app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', message: 'API is running' });
});
app.use('/api/settings', publicSettingsRoutes(pool, query));
// Upload endpoints
// Public upload endpoint (basic)
app.post('/api/image/upload', upload.single('image'), (req, res) => {
@ -197,6 +200,21 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
});
});
app.get('/api/public-file/:filename', (req, res) => {
const { filename } = req.params;
// Prevent path traversal attacks
if (filename.includes('..') || filename.includes('/')) {
return res.status(400).json({
error: true,
message: 'Invalid filename'
});
}
// Serve files from public uploads folder
res.sendFile(path.join(__dirname, '../public/uploads', filename));
});
app.use('/api/product-reviews', productReviewsRoutes(pool, query, authMiddleware(pool, query)));
app.use('/api/admin/product-reviews', productReviewsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
@ -205,6 +223,7 @@ app.use('/api/admin/coupons', couponsAdminRoutes(pool, query, adminAuthMiddlewar
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))); // Add new route
// Admin-only product image upload
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {

View file

@ -27,7 +27,7 @@ module.exports = (pool, query) => {
});
}
// Check if user is admin
if (!result.rows[0].is_admin) {
return res.status(403).json({
error: true,
@ -38,7 +38,6 @@ module.exports = (pool, query) => {
// Add user to request object
req.user = result.rows[0];
// Continue to next middleware/route handler
next();
} catch (error) {
return res.status(500).json({

View file

@ -30,7 +30,6 @@ module.exports = (pool, query) => {
// Add user to request object
req.user = result.rows[0];
// Continue to next middleware/route handler
next();
} catch (error) {
return res.status(500).json({

View file

@ -47,7 +47,6 @@ const fileFilter = (req, file, cb) => {
}
};
// Create the multer instance
const upload = multer({
storage,
fileFilter,

View file

@ -1,21 +1,9 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const nodemailer = require('nodemailer');
const config = require('../config');
const emailService = require('../services/emailService');
const router = express.Router();
const createTransporter = () => {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
};
const transporter = createTransporter();
module.exports = (pool, query) => {
// Register new user
@ -95,18 +83,15 @@ module.exports = (pool, query) => {
const loginLink = `${config.site.protocol}://${config.site.domain}/verify?code=${authCode}&email=${encodeURIComponent(email)}`;
await transporter.sendMail({
from: 'noreply@2many.ca',
to: email,
subject: 'Your Login Code',
html: `
<h1>Your login code is: ${authCode}</h1>
<p>This code will expire in 15 minutes.</p>
<p>Or click <a href="${loginLink}">here</a> to log in directly.</p>
`
});
try {
await emailService.sendLoginCodeEmail({
to: email,
code: authCode,
loginLink: loginLink
});
} catch (emailError) {
console.error('Failed to send login code email:', emailError);
}
let retObj = {
message: 'Login code sent to address: ' + email
}

View file

@ -152,7 +152,6 @@ module.exports = (pool, query, authMiddleware) => {
const commentThreads = [];
const commentMap = {};
// First, create a map of all comments
commentsResult.rows.forEach(comment => {
commentMap[comment.id] = {
...comment,
@ -173,7 +172,6 @@ module.exports = (pool, query, authMiddleware) => {
}
});
// Return the post with images and comments
res.json({
...post,
images: imagesResult.rows,
@ -245,7 +243,6 @@ module.exports = (pool, query, authMiddleware) => {
}
}
// Determine if comment needs moderation
const isApproved = req.user.is_admin ? true : false;
// Insert comment

View file

@ -10,7 +10,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get all blog posts (admin)
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -47,7 +47,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -112,7 +112,6 @@ module.exports = (pool, query, authMiddleware) => {
tags, featuredImagePath, status, publishNow
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -135,7 +134,6 @@ module.exports = (pool, query, authMiddleware) => {
remove: /[*+~.()'"!:@]/g // regex to remove characters
});
// Check if slug already exists
const slugCheck = await query(
'SELECT id FROM blog_posts WHERE slug = $1',
[slug]
@ -221,7 +219,7 @@ module.exports = (pool, query, authMiddleware) => {
tags, featuredImagePath, status, publishNow
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -365,7 +363,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -406,7 +404,7 @@ module.exports = (pool, query, authMiddleware) => {
const { postId } = req.params;
const { imagePath, caption, displayOrder } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -457,7 +455,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { postId, imageId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -8,7 +8,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get all pending comments (admin)
router.get('/pending', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -41,7 +41,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { postId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -120,7 +120,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { commentId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -169,7 +169,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { commentId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -12,7 +12,7 @@ module.exports = (pool, query, authMiddleware) => {
*/
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -68,7 +68,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -151,7 +151,7 @@ module.exports = (pool, query, authMiddleware) => {
blacklistedProducts
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -343,7 +343,7 @@ module.exports = (pool, query, authMiddleware) => {
blacklistedProducts
} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -605,7 +605,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -648,7 +648,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -0,0 +1,602 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
const nodemailer = require('nodemailer');
const config = require('../config');
// Create email transporter
const createTransporter = () => {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
};
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
router.use(authMiddleware);
/**
* Get all email templates
* GET /api/admin/email-templates
*/
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 settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
['email_templates']
);
// Transform settings into template objects
const templates = result.rows.map(setting => {
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(setting.value);
return {
id: setting.key,
...templateData
};
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
return null;
}
}).filter(Boolean); // Remove any null entries
res.json(templates);
} catch (error) {
next(error);
}
});
/**
* Get templates by type
* GET /api/admin/email-templates/type/:type
*/
router.get('/type/:type', async (req, res, next) => {
try {
const { type } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
['email_templates']
);
// Transform settings into template objects and filter by type
const templates = result.rows
.map(setting => {
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(setting.value);
if (templateData.type === type) {
return {
id: setting.key,
...templateData
};
}
return null;
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
return null;
}
})
.filter(Boolean); // Remove any null entries
res.json(templates);
} catch (error) {
next(error);
}
});
/**
* Get default template for a type
* GET /api/admin/email-templates/default/:type
*/
router.get('/default/:type', async (req, res, next) => {
try {
const { type } = req.params;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
['email_templates']
);
// Find the default template for the specified type
let defaultTemplate = null;
for (const setting of result.rows) {
try {
const templateData = JSON.parse(setting.value);
if (templateData.type === type && templateData.isDefault) {
defaultTemplate = {
id: setting.key,
...templateData
};
break;
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
if (defaultTemplate) {
res.json(defaultTemplate);
} else {
res.status(404).json({
error: true,
message: `No default template found for type: ${type}`
});
}
} catch (error) {
next(error);
}
});
/**
* Get a single template by ID
* GET /api/admin/email-templates/: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 the setting by key
const result = await query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (result.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(result.rows[0].value);
res.json({
id: result.rows[0].key,
...templateData
});
} catch (e) {
console.error(`Failed to parse template setting: ${id}`, e);
return res.status(500).json({
error: true,
message: 'Failed to parse template data'
});
}
} catch (error) {
next(error);
}
});
/**
* Create a new template
* POST /api/admin/email-templates
*/
router.post('/', async (req, res, next) => {
try {
const { name, type, subject, content, isDefault } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!name || !type || !subject || !content) {
return res.status(400).json({
error: true,
message: 'Name, type, subject, and content are required'
});
}
// Begin transaction for potential default template updates
const client = await pool.connect();
try {
await client.query('BEGIN');
// Generate a unique key for the setting
const templateKey = `email_template_${Date.now()}`;
// Create the template object
const templateData = {
name,
type,
subject,
content,
isDefault: isDefault || false,
createdAt: new Date().toISOString()
};
// If this template should be the default, unset any existing defaults
if (templateData.isDefault) {
// Get all settings with 'email_templates' category
const existingTemplates = await client.query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find and update any existing default templates of the same type
for (const setting of existingTemplates.rows) {
try {
const existingData = JSON.parse(setting.value);
if (existingData.type === type && existingData.isDefault) {
existingData.isDefault = false;
existingData.updatedAt = new Date().toISOString();
await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
[JSON.stringify(existingData), setting.key]
);
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
}
// Insert the new template
await client.query(
'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3)',
[templateKey, JSON.stringify(templateData), 'email_templates']
);
await client.query('COMMIT');
res.status(201).json({
id: templateKey,
...templateData
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Update a template
* PUT /api/admin/email-templates/:id
*/
router.put('/:id', async (req, res, next) => {
try {
const { id } = req.params;
const { name, type, subject, content, isDefault } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate required fields
if (!name || !type || !subject || !content) {
return res.status(400).json({
error: true,
message: 'Name, type, subject, and content are required'
});
}
// Begin transaction for potential default template updates
const client = await pool.connect();
try {
await client.query('BEGIN');
// Check if the template exists
const templateCheck = await client.query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (templateCheck.rows.length === 0) {
await client.query('ROLLBACK');
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
// Parse the existing template data
let existingData;
try {
existingData = JSON.parse(templateCheck.rows[0].value);
} catch (e) {
await client.query('ROLLBACK');
return res.status(500).json({
error: true,
message: 'Failed to parse existing template data'
});
}
// Create the updated template object
const templateData = {
name,
type,
subject,
content,
isDefault: isDefault !== undefined ? isDefault : existingData.isDefault,
createdAt: existingData.createdAt,
updatedAt: new Date().toISOString()
};
// If this template should be the default, unset any existing defaults
if (templateData.isDefault && (!existingData.isDefault || existingData.type !== type)) {
// Get all settings with 'email_templates' category
const existingTemplates = await client.query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find and update any existing default templates of the same type
for (const setting of existingTemplates.rows) {
if (setting.key === id) continue; // Skip the current template
try {
const otherData = JSON.parse(setting.value);
if (otherData.type === type && otherData.isDefault) {
otherData.isDefault = false;
otherData.updatedAt = new Date().toISOString();
await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
[JSON.stringify(otherData), setting.key]
);
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
}
// Update the template
await client.query(
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
[JSON.stringify(templateData), id]
);
await client.query('COMMIT');
res.json({
id,
...templateData
});
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
} catch (error) {
next(error);
}
});
/**
* Delete a template
* DELETE /api/admin/email-templates/: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 the template exists and is not a default template
const templateCheck = await query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (templateCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
// Parse the template data to check if it's a default template
try {
const templateData = JSON.parse(templateCheck.rows[0].value);
if (templateData.isDefault) {
return res.status(400).json({
error: true,
message: 'Cannot delete a default template. Please set another template as default first.'
});
}
} catch (e) {
console.error(`Failed to parse template setting: ${id}`, e);
}
// Delete the template
await query(
'DELETE FROM system_settings WHERE key = $1',
[id]
);
res.json({
success: true,
message: 'Template deleted successfully'
});
} catch (error) {
next(error);
}
});
/**
* Send a test email using a template
* POST /api/admin/email-templates/:id/test
*/
router.post('/:id/test', async (req, res, next) => {
try {
const { id } = req.params;
const { email, variables } = req.body;
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
message: 'Admin access required'
});
}
// Validate email address
if (!email) {
return res.status(400).json({
error: true,
message: 'Email address is required'
});
}
// Check if the template exists
const templateCheck = await query(
'SELECT * FROM system_settings WHERE key = $1',
[id]
);
if (templateCheck.rows.length === 0) {
return res.status(404).json({
error: true,
message: 'Template not found'
});
}
// Parse the template data
let templateData;
try {
templateData = JSON.parse(templateCheck.rows[0].value);
} catch (e) {
console.error(`Failed to parse template setting: ${id}`, e);
return res.status(500).json({
error: true,
message: 'Failed to parse template data'
});
}
// Replace variables in template
let emailContent = templateData.content;
let emailSubject = templateData.subject;
if (variables) {
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
emailContent = emailContent.replace(new RegExp(placeholder, 'g'), value);
emailSubject = emailSubject.replace(new RegExp(placeholder, 'g'), value);
}
}
// Create a transporter
const transporter = createTransporter();
// Send the test email
await transporter.sendMail({
from: config.email.reply,
to: email,
subject: `[TEST] ${emailSubject}`,
html: emailContent
});
res.json({
success: true,
message: `Test email sent to ${email}`
});
} catch (error) {
next(error);
}
});
/**
* Utility function to get a template by type
* @param {string} type - Template type
* @returns {Promise<Object|null>} Template object or null if not found
*/
async function getTemplateByType(type) {
try {
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find the default template for the specified type
let defaultTemplate = null;
for (const setting of result.rows) {
try {
const templateData = JSON.parse(setting.value);
if (templateData.type === type && templateData.isDefault) {
defaultTemplate = {
id: setting.key,
...templateData
};
break;
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
return defaultTemplate;
} catch (error) {
console.error('Error getting template by type:', error);
return null;
}
}
return router;
};

View file

@ -1,19 +1,6 @@
const express = require('express');
const router = express.Router();
const nodemailer = require('nodemailer');
const config = require('../config');
// Helper function to create email transporter
const createTransporter = () => {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
};
const emailService = require('../services/emailService'); // Import email service
module.exports = (pool, query, authMiddleware) => {
// Apply authentication middleware to all routes
@ -22,7 +9,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get all orders (admin only)
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -52,7 +39,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -124,7 +111,7 @@ module.exports = (pool, query, authMiddleware) => {
const { id } = req.params;
const { status } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -171,7 +158,7 @@ module.exports = (pool, query, authMiddleware) => {
const { id } = req.params;
const { status, shippingData, sendNotification } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -240,12 +227,66 @@ module.exports = (pool, query, authMiddleware) => {
const orderItems = itemsResult.rows;
// Send email notification
await sendShippingNotification(
order,
orderItems,
shippingData
);
// Generate items HTML table
const itemsHtml = orderItems.map(item => `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
</tr>
`).join('');
// Generate carrier tracking link
let trackingLink = '#';
const shipper = shippingData.shipper || '';
const trackingNumber = shippingData.trackingNumber;
if (trackingNumber) {
// Match exactly with the values from the dropdown
switch(shipper) {
case 'USPS':
trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`;
break;
case 'UPS':
trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`;
break;
case 'FedEx':
trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`;
break;
case 'DHL':
trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`;
break;
case 'Canada Post':
trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`;
break;
case 'Purolator':
trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`;
break;
default:
// For "other" or any carrier not in our list
// Just make the tracking number text without a link
trackingLink = '#';
break;
}
}
// Format shipping date
const shippedDate = new Date(shippingData.shippedDate || new Date()).toLocaleDateString();
// Send email notification using template system
await emailService.sendShippingNotification({
to: order.email,
first_name: order.first_name,
order_id: order.id.substring(0, 8),
tracking_number: shippingData.trackingNumber || 'N/A',
carrier: shippingData.shipper || 'Standard Shipping',
tracking_link: trackingLink,
shipped_date: shippedDate,
estimated_delivery: shippingData.estimatedDelivery || 'N/A',
items_html: itemsHtml,
customer_message: shippingData.customerMessage || ''
});
// Log the notification in the database
await client.query(`
@ -272,135 +313,5 @@ module.exports = (pool, query, authMiddleware) => {
}
});
// Helper function to send shipping notification email
async function sendShippingNotification(order, orderItems, shippingData) {
try {
const transporter = createTransporter();
// Calculate order total
const orderTotal = orderItems.reduce((sum, item) => {
return sum + (parseFloat(item.price_at_purchase) * item.quantity);
}, 0);
// Format shipping date
const shippedDate = new Date(shippingData.shippedDate || new Date()).toLocaleDateString();
// Generate items HTML table
const itemsHtml = orderItems.map(item => `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
</tr>
`).join('');
// Generate carrier tracking link
let trackingLink = '#';
const shipper = shippingData.shipper || '';
const trackingNumber = shippingData.trackingNumber;
if (trackingNumber) {
// Match exactly with the values from the dropdown
switch(shipper) {
case 'USPS':
trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`;
break;
case 'UPS':
trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`;
break;
case 'FedEx':
trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`;
break;
case 'DHL':
trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`;
break;
case 'Canada Post':
trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`;
break;
case 'Purolator':
trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`;
break;
default:
// For "other" or any carrier not in our list
// Just make the tracking number text without a link
trackingLink = '#';
break;
}
}
// Build email HTML
const emailHtml = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f8f8f8; padding: 20px; text-align: center;">
<h1 style="color: #333;">Your Order Has Shipped!</h1>
<p style="font-size: 16px;">Order #${order.id.substring(0, 8)}</p>
</div>
<div style="padding: 20px;">
<p>Hello ${order.first_name},</p>
<p>Good news! Your order has been shipped and is on its way to you.</p>
${shippingData.customerMessage ? `<p><strong>Message from our team:</strong> ${shippingData.customerMessage}</p>` : ''}
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #4caf50;">
<h3 style="margin-top: 0;">Shipping Details</h3>
<p><strong>Carrier:</strong> ${shippingData.shipper || 'Standard Shipping'}</p>
<p><strong>Tracking Number:</strong> <a href="${trackingLink}" target="_blank">${shippingData.trackingNumber}</a></p>
<p><strong>Shipped On:</strong> ${shippedDate}</p>
${shippingData.estimatedDelivery ? `<p><strong>Estimated Delivery:</strong> ${shippingData.estimatedDelivery}</p>` : ''}
</div>
<div style="margin-top: 30px;">
<h3>Order Summary</h3>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background-color: #f2f2f2;">
<th style="padding: 10px; text-align: left;">Item</th>
<th style="padding: 10px; text-align: left;">Qty</th>
<th style="padding: 10px; text-align: left;">Price</th>
<th style="padding: 10px; text-align: left;">Total</th>
</tr>
</thead>
<tbody>
${itemsHtml}
</tbody>
<tfoot>
<tr>
<td colspan="3" style="padding: 10px; text-align: right;"><strong>Total:</strong></td>
<td style="padding: 10px;"><strong>$${orderTotal.toFixed(2)}</strong></td>
</tr>
</tfoot>
</table>
</div>
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
<p>Thank you for your purchase! If you have any questions, please contact our customer service.</p>
</div>
</div>
<div style="background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;">
<p>&copy; ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
</div>
</div>
`;
// Send the email
await transporter.sendMail({
from: config.email.reply,
to: order.email,
subject: `Your Order #${order.id.substring(0, 8)} Has Shipped!`,
html: emailHtml
});
console.log(`Shipping notification email sent to ${order.email}`);
return true;
} catch (error) {
console.error('Error sending shipping notification:', error);
throw error;
}
}
return router;
};

View file

@ -157,7 +157,7 @@ module.exports = (pool, query, authMiddleware) => {
const { id } = req.params;
const { enabled, email, threshold } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -120,7 +120,7 @@ module.exports = (pool, query, authMiddleware) => {
const isVerifiedPurchase = purchaseCheck.rows.length > 0;
// Check if user is admin
const isAdmin = req.user.is_admin || false;
// Only allow reviews if user has purchased the product or is an admin

View file

@ -8,7 +8,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get all pending reviews (admin)
router.get('/pending', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -41,7 +41,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { productId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -120,7 +120,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { reviewId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -169,7 +169,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { reviewId } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -0,0 +1,52 @@
const express = require('express');
const router = express.Router();
const SystemSettings = require('../models/SystemSettings');
module.exports = (pool, query) => {
/**
* Get public branding settings
* GET /api/settings/branding
*/
router.get('/branding', async (req, res, next) => {
try {
// Get all settings with 'branding' category
const settings = await SystemSettings.getSettingsByCategory(pool, query, 'branding');
// Convert array of settings to an object for easier client-side use
const brandingSettings = {};
settings.forEach(setting => {
brandingSettings[setting.key] = setting.value;
});
res.json(brandingSettings);
} catch (error) {
next(error);
}
});
/**
* Get basic public settings (for meta tags, favicon, etc.)
* GET /api/settings/meta
*/
router.get('/meta', async (req, res, next) => {
try {
// Get relevant settings
const siteNameSetting = await SystemSettings.getSetting(pool, query, 'site_name');
const siteDescriptionSetting = await SystemSettings.getSetting(pool, query, 'site_description');
const faviconSetting = await SystemSettings.getSetting(pool, query, 'favicon_url');
// Create response object
const metaSettings = {
siteName: siteNameSetting?.value || 'Rocks, Bones & Sticks',
siteDescription: siteDescriptionSetting?.value || '',
faviconUrl: faviconSetting?.value || ''
};
res.json(metaSettings);
} catch (error) {
next(error);
}
});
return router;
};

View file

@ -14,7 +14,7 @@ module.exports = (pool, query, authMiddleware) => {
*/
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -46,7 +46,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { category } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -68,7 +68,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { key } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -99,7 +99,7 @@ module.exports = (pool, query, authMiddleware) => {
const { key } = req.params;
const { value, category } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -138,7 +138,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { settings } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -189,7 +189,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { key } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -19,7 +19,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get all users
router.get('/', async (req, res, next) => {
try {
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -53,7 +53,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { id } = req.params;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -95,7 +95,7 @@ module.exports = (pool, query, authMiddleware) => {
const { id } = req.params;
const { is_disabled, internal_notes, is_admin} = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,
@ -143,7 +143,7 @@ module.exports = (pool, query, authMiddleware) => {
try {
const { to, name, subject, message } = req.body;
// Check if user is admin
if (!req.user.is_admin) {
return res.status(403).json({
error: true,

View file

@ -0,0 +1,278 @@
const nodemailer = require('nodemailer');
const config = require('../config');
const { query, pool } = require('../db');
/**
* Service for sending emails with templates
*/
const emailService = {
/**
* Create email transporter
* @returns {Object} Configured nodemailer transporter
*/
createTransporter() {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
},
/**
* Get a template by type, preferring the default one
* @param {string} type - Template type
* @returns {Promise<Object|null>} Template object or null if not found
*/
async getTemplateByType(type) {
try {
// Get all settings with 'email_templates' category
const result = await query(
'SELECT * FROM system_settings WHERE category = $1',
['email_templates']
);
// Find the default template for the specified type
let defaultTemplate = null;
let fallbackTemplate = null;
for (const setting of result.rows) {
try {
console.log(setting.value, typeof setting.value)
const templateData = JSON.parse(setting.value);
if (templateData.type === type) {
if (templateData.isDefault) {
defaultTemplate = {
id: setting.key,
...templateData
};
break; // Found the default template
} else if (!fallbackTemplate) {
// Keep a fallback template in case no default is found
fallbackTemplate = {
id: setting.key,
...templateData
};
}
}
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
}
}
// Return default template if found, otherwise return fallback or null
return defaultTemplate || fallbackTemplate || null;
} catch (error) {
console.error('Error getting template by type:', error);
return null;
}
},
/**
* Replace template variables with actual values
* @param {string} content - Template content
* @param {Object} variables - Variable values
* @returns {string} Processed content
*/
replaceVariables(content, variables) {
let processedContent = content;
if (variables) {
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
const regex = new RegExp(placeholder, 'g');
processedContent = processedContent.replace(regex, value || '');
}
}
return processedContent;
},
/**
* Send an email using a template
* @param {Object} options - Email options
* @param {string} options.to - Recipient email address
* @param {string} options.templateType - Template type
* @param {Object} options.variables - Template variables
* @param {string} [options.from] - Sender email (optional, defaults to config)
* @param {string} [options.subject] - Custom subject (optional, defaults to template subject)
* @param {string} [options.cc] - CC recipients (optional)
* @param {string} [options.bcc] - BCC recipients (optional)
* @returns {Promise<boolean>} Success status
*/
async sendTemplatedEmail(options) {
try {
const { to, templateType, variables, from, subject, cc, bcc } = options;
// Get template
const template = await this.getTemplateByType(templateType);
if (!template) {
throw new Error(`No template found for type: ${templateType}`);
}
// Replace variables in content and subject
const emailContent = this.replaceVariables(template.content, variables);
const emailSubject = subject || this.replaceVariables(template.subject, variables);
// Create transporter
const transporter = this.createTransporter();
// Send email
const result = await transporter.sendMail({
from: from || config.email.reply,
to,
cc,
bcc,
subject: emailSubject,
html: emailContent
});
console.log(`Email sent: ${result.messageId}`);
return true;
} catch (error) {
console.error('Error sending templated email:', error);
throw error;
}
},
/**
* Send a login code email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.code - Login verification code
* @param {string} options.loginLink - Direct login link
* @returns {Promise<boolean>} Success status
*/
async sendLoginCodeEmail(options) {
const { to, code, loginLink } = options;
return this.sendTemplatedEmail({
to,
templateType: 'login_code',
variables: {
code,
loginLink,
email: to
}
});
},
/**
* Send a shipping notification email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - Customer's first name
* @param {string} options.order_id - Order ID
* @param {string} options.tracking_number - Tracking number
* @param {string} options.carrier - Shipping carrier
* @param {string} options.tracking_link - Tracking link
* @param {string} options.shipped_date - Ship date
* @param {string} options.estimated_delivery - Estimated delivery
* @param {string} options.items_html - Order items HTML table
* @param {string} options.customer_message - Custom message
* @returns {Promise<boolean>} Success status
*/
async sendShippingNotification(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'shipping_notification',
variables: options
});
},
/**
* Send an order confirmation email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - Customer's first name
* @param {string} options.order_id - Order ID
* @param {string} options.order_date - Order date
* @param {string} options.order_total - Order total
* @param {string} options.shipping_address - Shipping address
* @param {string} options.items_html - Order items HTML table
* @returns {Promise<boolean>} Success status
*/
async sendOrderConfirmation(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'order_confirmation',
variables: options
});
},
/**
* Send a low stock alert email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.product_name - Product name
* @param {string} options.current_stock - Current stock level
* @param {string} options.threshold - Stock threshold
* @returns {Promise<boolean>} Success status
*/
async sendLowStockAlert(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'low_stock_alert',
variables: options
});
},
/**
* Send a welcome email
* @param {Object} options - Options
* @param {string} options.to - Recipient email
* @param {string} options.first_name - User's first name
* @returns {Promise<boolean>} Success status
*/
async sendWelcomeEmail(options) {
return this.sendTemplatedEmail({
to: options.to,
templateType: 'welcome_email',
variables: {
first_name: options.first_name,
email: options.to
}
});
},
/**
* Log an email in the database
* @param {Object} emailData - Email data to log
* @param {string} emailData.recipient - Recipient email
* @param {string} emailData.subject - Email subject
* @param {string} emailData.sent_by - User ID who sent the email
* @param {string} [emailData.template_id] - Template ID used
* @param {string} [emailData.template_type] - Template type used
* @returns {Promise<Object>} Log entry
*/
async logEmail(emailData) {
try {
const result = await query(
`INSERT INTO email_logs
(recipient, subject, sent_by, template_id, template_type, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
emailData.recipient,
emailData.subject,
emailData.sent_by,
emailData.template_id || null,
emailData.template_type || null,
'sent'
]
);
return result.rows[0];
} catch (error) {
console.error('Error logging email:', error);
// Don't throw error, just log it
return null;
}
}
};
module.exports = emailService;

View file

@ -1,27 +1,10 @@
// Create a new file: src/services/notificationService.js
const nodemailer = require('nodemailer');
const config = require('../config');
const emailService = require('./emailService');
/**
* Service for handling notifications including stock alerts
*/
const notificationService = {
/**
* Create email transporter
* @returns {Object} Configured nodemailer transporter
*/
createTransporter() {
return nodemailer.createTransport({
host: config.email.host,
port: config.email.port,
auth: {
user: config.email.user,
pass: config.email.pass
}
});
},
/**
* Process pending low stock notifications
* @param {Object} pool - Database connection pool
@ -77,21 +60,18 @@ const notificationService = {
return 0;
}
// Initialize email transporter
const transporter = this.createTransporter();
// Send notifications for each low stock product
for (const product of lowStockProducts.rows) {
console.log("LOW STOCK ON: ", JSON.stringify(product, null, 4))
const notification = product.stock_notification;
try {
// Send email notification
await transporter.sendMail({
from: config.email.reply,
// Send email notification using template
await emailService.sendLowStockAlert({
to: notification.email,
subject: `Low Stock Alert: ${product.name}`,
html: this.generateLowStockEmailTemplate(product)
product_name: product.name,
current_stock: product.stock_quantity.toString(),
threshold: notification.threshold.toString()
});
// Mark one notification as processed
@ -129,46 +109,6 @@ const notificationService = {
} finally {
client.release();
}
},
/**
* Generate email template for low stock notification
* @param {Object} product - Product with low stock
* @returns {string} HTML email template
*/
generateLowStockEmailTemplate(product) {
const stockNotification = product.stock_notification;
const threshold = stockNotification.threshold || 0;
return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #f8f8f8; padding: 20px; text-align: center;">
<h1 style="color: #ff6b6b;">Low Stock Alert</h1>
</div>
<div style="padding: 20px;">
<p>Hello,</p>
<p>This is an automated notification to inform you that the following product is running low on stock:</p>
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;">
<h3 style="margin-top: 0;">${product.name}</h3>
<p><strong>Current Stock:</strong> ${product.stock_quantity}</p>
<p><strong>Threshold:</strong> ${threshold}</p>
</div>
<p>You might want to restock this item soon to avoid running out of inventory.</p>
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
<p>This is an automated notification. You received this because you set up stock notifications for this product.</p>
</div>
</div>
<div style="background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;">
<p>&copy; ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
</div>
</div>
`;
}
};

View file

@ -27,7 +27,7 @@ VALUES
('smtp_from_name', NULL, 'email'),
-- Site Settings
('site_name', NULL, 'site'),
-- ('site_name', NULL, 'site'),
('site_domain', NULL, 'site'),
('site_api_domain', NULL, 'site'),
('site_protocol', NULL, 'site'),

View file

@ -0,0 +1,53 @@
-- Add email_templates category in system_settings if needed
INSERT INTO system_settings (key, value, category) VALUES
('email_templates_enabled', 'true', 'email')
ON CONFLICT (key) DO NOTHING;
-- Add email_logs table for template logs if it doesn't exist
ALTER TABLE email_logs ADD COLUMN IF NOT EXISTS template_id VARCHAR(255);
ALTER TABLE email_logs ADD COLUMN IF NOT EXISTS template_type VARCHAR(50);
-- Create default login code template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_login_code_default',
'{"name":"Login Code Template","type":"login_code","subject":"Your Login Code","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><h1>Your login code is: {{code}}</h1><p>This code will expire in 15 minutes.</p><p>Or click <a href=\"{{loginLink}}\">here</a> to log in directly.</p></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default shipping notification template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_shipping_notification_default',
'{"name":"Shipping Notification Template","type":"shipping_notification","subject":"Your Order Has Shipped!","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Your Order Has Shipped!</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Good news! Your order has been shipped and is on its way to you.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #4caf50;\"><h3 style=\"margin-top: 0;\">Shipping Details</h3><p><strong>Carrier:</strong> {{carrier}}</p><p><strong>Tracking Number:</strong> <a href=\"{{tracking_link}}\" target=\"_blank\">{{tracking_number}}</a></p><p><strong>Shipped On:</strong> {{shipped_date}}</p><p><strong>Estimated Delivery:</strong> {{estimated_delivery}}</p></div><div style=\"margin-top: 30px;\"><h3>Order Summary</h3><table style=\"width: 100%; border-collapse: collapse;\"><thead><tr style=\"background-color: #f2f2f2;\"><th style=\"padding: 10px; text-align: left;\">Item</th><th style=\"padding: 10px; text-align: left;\">Qty</th><th style=\"padding: 10px; text-align: left;\">Price</th><th style=\"padding: 10px; text-align: left;\">Total</th></tr></thead><tbody>{{items_html}}</tbody></table></div><div style=\"margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;\"><p>Thank you for your purchase!</p></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default order confirmation template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_order_confirmation_default',
'{"name":"Order Confirmation Template","type":"order_confirmation","subject":"Order Confirmation","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Order Confirmation</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Thank you for your order! We are processing it now and will send you another email when it ships.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0;\"><h3 style=\"margin-top: 0;\">Order Details</h3><p><strong>Order Date:</strong> {{order_date}}</p><p><strong>Order Total:</strong> {{order_total}}</p><p><strong>Shipping To:</strong> {{shipping_address}}</p></div><div style=\"margin-top: 30px;\"><h3>Order Summary</h3><table style=\"width: 100%; border-collapse: collapse;\"><thead><tr style=\"background-color: #f2f2f2;\"><th style=\"padding: 10px; text-align: left;\">Item</th><th style=\"padding: 10px; text-align: left;\">Qty</th><th style=\"padding: 10px; text-align: left;\">Price</th><th style=\"padding: 10px; text-align: left;\">Total</th></tr></thead><tbody>{{items_html}}</tbody></table></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default low stock alert template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_low_stock_alert_default',
'{"name":"Low Stock Alert Template","type":"low_stock_alert","subject":"Low Stock Alert: {{product_name}}","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #ff6b6b;\">Low Stock Alert</h1></div><div style=\"padding: 20px;\"><p>Hello,</p><p>This is an automated notification to inform you that the following product is running low on stock:</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;\"><h3 style=\"margin-top: 0;\">{{product_name}}</h3><p><strong>Current Stock:</strong> {{current_stock}}</p><p><strong>Threshold:</strong> {{threshold}}</p></div><p>You might want to restock this item soon to avoid running out of inventory.</p></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;
-- Create default welcome email template
INSERT INTO system_settings (key, value, category)
VALUES (
'email_template_welcome_email_default',
'{"name":"Welcome Email Template","type":"welcome_email","subject":"Welcome to Rocks, Bones & Sticks!","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Welcome to Rocks, Bones & Sticks!</h1></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Thank you for creating an account with us. We are excited to have you join our community of natural curiosity enthusiasts!</p><p>As a member, you will enjoy:</p><ul><li>Access to our unique collection of natural specimens</li><li>Special offers and promotions</li><li>Early access to new items</li></ul><p>Start exploring our collections today and discover the beauty of nature!</p><div style=\"margin-top: 30px; text-align: center;\"><a href=\"#\" style=\"background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;\">Shop Now</a></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>&copy; 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
'email_templates'
)
ON CONFLICT (key) DO NOTHING;

View file

@ -0,0 +1,49 @@
-- Add branding category to system_settings if needed
INSERT INTO system_settings (key, value, category) VALUES
('branding_enabled', 'true', 'branding')
ON CONFLICT (key) DO NOTHING;
-- Create default branding settings
INSERT INTO system_settings (key, value, category)
VALUES
('site_name', 'Rocks, Bones & Sticks', 'branding'),
('site_main_page_title', 'Discover Natural Wonders', 'branding'),
('site_main_page_subtitle', 'Unique rocks, bones, and sticks from around my backyard', 'branding'),
('site_main_newsletter_desc', 'Subscribe to our newsletter for updates on new items and promotions', 'branding'),
('site_main_bottom_sting', 'Ready to explore more?', 'branding'),
('site_description', 'Your premier source for natural curiosities and unique specimens', 'branding'),
('site_quicklinks_title', 'Quick Links', 'branding'),
('site_connect', 'Connect With Us', 'branding'),
('blog_title', 'Our Blog', 'branding'),
('blog_desc', 'Discover insights about our natural collections, sourcing adventures, and unique specimens', 'branding'),
('blog_no_content_title', 'No blog posts found', 'branding'),
('blog_no_content_subtitle', 'Check back soon for new content', 'branding'),
('blog_search', 'Search blog posts', 'branding'),
('cart_empty', 'Your Cart is Empty', 'branding'),
('cart_empty_subtitle', 'Looks like you have not added any items to your cart yet.', 'branding'),
('product_title', 'Products', 'branding'),
('orders_title', 'My Orders', 'branding'),
('orders_empty', 'You have not placed any orders yet.', 'branding'),
('default_mode', 'light', 'branding'),
('copyright_text', '© 2025 Rocks, Bones & Sticks. All rights reserved.', 'branding'),
('light_primary_color', '#7e57c2', 'branding'),
('light_secondary_color', '#ffb300', 'branding'),
('light_background_color', '#f5f5f5', 'branding'),
('light_surface_color', '#ffffff', 'branding'),
('light_text_color', '#000000', 'branding'),
('dark_primary_color', '#9575cd', 'branding'),
('dark_secondary_color', '#ffd54f', 'branding'),
('dark_background_color', '#212121', 'branding'),
('dark_surface_color', '#424242', 'branding'),
('dark_text_color', '#ffffff', 'branding'),
('logo_url', '', 'branding'),
('favicon_url', '', 'branding')
ON CONFLICT (key) DO NOTHING;

View file

@ -1,146 +1,180 @@
project/
Rock/
├── git/
├── frontend/
│ ├── node_modules/
│ ├── src/
│ │ ├── utils/
│ │ │ └── imageUtils.js (4/24/2025)
│ │ ├── theme/
│ │ │ ├── ThemeProvider.jsx (4/24/2025)
│ │ │ └── index.js (4/24/2025)
│ │ ├── store/
│ │ │ └── index.js (4/24/2025)
│ │ ├── services/
│ │ │ ├── settingsAdminService.js (4/25/2025)
│ │ │ ├── productService.js (4/24/2025)
│ │ │ ├── imageService.js (4/24/2025)
│ │ │ ├── couponService.js (NEW - 4/29/2025)
│ │ │ ├── categoryAdminService.js (4/25/2025)
│ │ │ ├── cartService.js (4/25/2025)
│ │ │ ├── blogService.js (NEW - 4/29/2025)
│ │ │ ├── blogAdminService.js (NEW - 4/29/2025)
│ │ │ ├── authService.js (4/26/2025)
│ │ │ ├── api.js (4/24/2025)
│ │ │ └── adminService.js (4/26/2025)
│ │ ├── pages/
│ │ │ ├── Admin/
│ │ │ │ ├── SettingsPage.jsx (4/26/2025)
│ │ │ │ ├── ReportsPage.jsx (4/28/2025)
│ │ │ │ ├── ProductsPage.jsx (4/24/2025)
│ │ │ │ ├── ProductEditPage.jsx (4/28/2025)
│ │ │ │ ├── OrdersPage.jsx (4/26/2025)
│ │ │ │ ├── DashboardPage.jsx (4/28/2025)
│ │ │ │ ├── CustomersPage.jsx (4/26/2025)
│ │ │ │ ├── CouponsPage.jsx (NEW - 4/29/2025)
│ │ │ │ ├── CategoriesPage.jsx (4/25/2025)
│ │ │ │ ├── BlogPage.jsx (NEW - 4/29/2025)
│ │ │ │ └── BlogEditPage.jsx (NEW - 4/29/2025)
│ │ │ ├── BlogCommentsPage.jsx (NEW - 4/29/2025)
│ │ │ ├── VerifyPage.jsx (4/24/2025)
│ │ │ ├── UserOrdersPage.jsx (4/26/2025)
│ │ │ ├── RegisterPage.jsx (4/24/2025)
│ │ │ ├── ProductsPage.jsx (4/25/2025)
│ │ │ ├── ProductDetailPage.jsx (4/26/2025)
│ │ │ ├── PaymentSuccessPage.jsx (4/27/2025)
│ │ │ ├── PaymentCancelPage.jsx (4/26/2025)
│ │ │ ├── NotFoundPage.jsx (4/24/2025)
│ │ │ ├── LoginPage.jsx (4/24/2025)
│ │ │ ├── HomePage.jsx (4/25/2025)
│ │ │ ├── CouponRedemptionsPage.jsx (NEW - 4/29/2025)
│ │ │ ├── CouponEditPage.jsx (NEW - 4/29/2025)
│ │ │ ├── CheckoutPage.jsx (4/28/2025)
│ │ │ ├── CartPage.jsx (NEW - 4/29/2025)
│ │ │ ├── BlogPage.jsx (NEW - 4/29/2025)
│ │ │ └── BlogDetailPage.jsx (NEW - 4/29/2025)
│ │ ├── layouts/
│ │ │ ├── MainLayout.jsx (4/29/2025)
│ │ │ ├── AuthLayout.jsx (4/24/2025)
│ │ │ └── AdminLayout.jsx (4/29/2025)
│ │ │ │ ├── ReportsPage.jsx
│ │ │ │ ├── ProductEditPage.jsx
│ │ │ │ ├── OrdersPage.jsx
│ │ │ │ ├── EmailTemplatesPage.jsx
│ │ │ │ ├── SettingsPage.jsx
│ │ │ │ ├── BlogEditPage.jsx
│ │ │ │ ├── CategoriesPage.jsx
│ │ │ │ ├── CustomersPage.jsx
│ │ │ │ ├── CouponsPage.jsx
│ │ │ │ ├── ProductReviewsPage.jsx
│ │ │ │ ├── BlogPage.jsx
│ │ │ │ ├── BlogCommentsPage.jsx
│ │ │ │ ├── DashboardPage.jsx
│ │ │ │ └── ProductsPage.jsx
│ │ │ ├── CheckoutPage.jsx
│ │ │ ├── CouponEditPage.jsx
│ │ │ ├── ProductsPage.jsx
│ │ │ ├── UserOrdersPage.jsx
│ │ │ ├── CartPage.jsx
│ │ │ ├── ProductDetailPage.jsx
│ │ │ ├── BlogDetailPage.jsx
│ │ │ ├── BlogPage.jsx
│ │ │ ├── CouponRedemptionsPage.jsx
│ │ │ ├── HomePage.jsx
│ │ │ ├── LoginPage.jsx
│ │ │ ├── PaymentSuccessPage.jsx
│ │ │ ├── RegisterPage.jsx
│ │ │ ├── VerifyPage.jsx
│ │ │ ├── NotFoundPage.jsx
│ │ │ └── PaymentCancelPage.jsx
│ │ ├── services/
│ │ │ ├── emailTemplateService.js
│ │ │ ├── blogAdminService.js
│ │ │ ├── adminService.js
│ │ │ ├── couponService.js
│ │ │ ├── productService.js
│ │ │ ├── productReviewService.js
│ │ │ ├── settingsAdminService.js
│ │ │ ├── imageService.js
│ │ │ ├── cartService.js
│ │ │ ├── categoryAdminService.js
│ │ │ ├── authService.js
│ │ │ ├── blogService.js
│ │ │ └── api.js
│ │ ├── components/
│ │ │ ├── ProductReviews.jsx
│ │ │ ├── OrderStatusDialog.jsx
│ │ │ ├── ImageUploader.jsx
│ │ │ ├── CouponInput.jsx
│ │ │ ├── EmailDialog.jsx
│ │ │ ├── StripePaymentForm.jsx
│ │ │ ├── Footer.jsx
│ │ │ ├── ProductImage.jsx
│ │ │ ├── ProtectedRoute.jsx
│ │ │ ├── Notifications.jsx
│ │ │ └── ProductRatingDisplay.jsx
│ │ ├── hooks/
│ │ │ ├── settingsAdminHooks.js (4/25/2025)
│ │ │ ├── reduxHooks.js (4/26/2025)
│ │ │ ├── couponAdminHooks.js (NEW - 4/29/2025)
│ │ │ ├── categoryAdminHooks.js (4/24/2025)
│ │ │ ├── blogHooks.js (NEW - 4/29/2025)
│ │ │ ├── apiHooks.js (4/26/2025)
│ │ │ └── adminHooks.js (4/26/2025)
│ │ │ ├── apiHooks.js
│ │ │ ├── blogHooks.js
│ │ │ ├── emailTemplateHooks.js
│ │ │ ├── adminHooks.js
│ │ │ ├── productReviewHooks.js
│ │ │ ├── reduxHooks.js
│ │ │ ├── couponAdminHooks.js
│ │ │ ├── settingsAdminHooks.js
│ │ │ └── categoryAdminHooks.js
│ │ ├── layouts/
│ │ │ ├── AdminLayout.jsx
│ │ │ ├── MainLayout.jsx
│ │ │ └── AuthLayout.jsx
│ │ ├── features/
│ │ │ ├── ui/
│ │ │ │ └── uiSlice.js (4/24/2025)
│ │ │ │ └── uiSlice.js
│ │ │ ├── cart/
│ │ │ │ └── cartSlice.js (NEW - 4/29/2025)
│ │ │ └── auth/
│ │ │ └── authSlice.js (4/26/2025)
│ │ │ │ └── cartSlice.js
│ │ │ ├── auth/
│ │ │ │ └── authSlice.js
│ │ │ └── theme/
│ │ │ ├── index.js
│ │ │ └── ThemeProvider.jsx
│ │ ├── utils/
│ │ │ └── imageUtils.js
│ │ ├── store/
│ │ │ └── index.js
│ │ ├── context/
│ │ │ └── StripeContext.jsx (4/28/2025)
│ │ ├── components/
│ │ │ ├── StripePaymentForm.jsx (4/26/2025)
│ │ │ ├── ProtectedRoute.jsx (4/24/2025)
│ │ │ ├── ProductImage.jsx (4/24/2025)
│ │ │ ├── OrderStatusDialog.jsx (4/26/2025)
│ │ │ ├── Notifications.jsx (4/24/2025)
│ │ │ ├── ImageUploader.jsx (4/24/2025)
│ │ │ ├── Footer.jsx (4/25/2025)
│ │ │ ├── EmailDialog.jsx (4/25/2025)
│ │ │ └── CouponInput.jsx (NEW - 4/29/2025)
│ │ │ └── StripeContext.jsx
│ │ ├── assets/
│ │ │ ├── main.jsx (4/24/2025)
│ │ │ └── config.js (4/24/2025)
│ │ └── App.jsx (4/24/2025)
├── db/
│ ├── test/
│ └── init/
│ ├── 16-blog-schema.sql (NEW)
│ ├── 15-coupon.sql (NEW)
│ ├── 14-product-notifications.sql (NEW)
│ ├── 13-cart-metadata.sql
│ ├── 12-shipping-orders.sql
│ ├── 11-notifications.sql
│ ├── 10-payment.sql
│ ├── 09-system-settings.sql
│ ├── 08-create-email.sql
│ ├── 07-user-keys.sql
│ ├── 06-product-categories.sql
│ ├── 05-admin-role.sql
│ ├── 04-product-images.sql
│ ├── 03-api-key.sql
│ ├── 02-seed.sql
│ └── 01-schema.sql
│ │ │ ├── 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
├── backend/
│ ├── node_modules/
│ ├── src/
│ │ ├── services/
│ │ │ ├── shippingService.js
│ │ │ └── notificationService.js
│ │ ├── routes/
│ │ │ ├── userOrders.js
│ │ │ ├── userAdmin.js
│ │ │ ├── stripePayment.js
│ │ │ ├── shipping.js
│ │ │ ├── settingsAdmin.js
│ │ │ ├── products.js
│ │ │ ├── productAdminImages.js
│ │ │ ├── cart.js
│ │ │ ├── couponAdmin.js
│ │ │ ├── emailTemplatesAdmin.js
│ │ │ ├── blogAdmin.js
│ │ │ ├── productAdmin.js
│ │ │ ├── orderAdmin.js
│ │ │ ├── images.js
│ │ │ ├── couponAdmin.js (NEW - Large file: 18.7 KB)
│ │ │ ├── settingsAdmin.js
│ │ │ ├── blog.js
│ │ │ ├── auth.js
│ │ │ ├── productReviews.js
│ │ │ ├── stripePayment.js
│ │ │ ├── products.js
│ │ │ ├── productReviewsAdmin.js
│ │ │ ├── blogCommentsAdmin.js
│ │ │ ├── userAdmin.js
│ │ │ ├── categoryAdmin.js
│ │ │ ├── cart.js (Updated - now 39.6 KB)
│ │ │ ├── blogCommentsAdmin.js (NEW)
│ │ │ ├── blogAdmin.js (NEW)
│ │ │ ├── blog.js (NEW)
│ │ │ └── auth.js
│ │ ├── models/
│ │ │ └── SystemSettings.js
│ │ │ ├── shipping.js
│ │ │ ├── images.js
│ │ │ ├── userOrders.js
│ │ │ └── productAdminImages.js
│ │ ├── middleware/
│ │ │ ├── upload.js
│ │ │ ├── auth.js
│ │ │ └── adminAuth.js
│ │ │ ├── adminAuth.js
│ │ │ └── auth.js
│ │ ├── services/
│ │ │ ├── shippingService.js
│ │ │ ├── emailService.js
│ │ │ └── notificationService.js
│ │ ├── models/
│ │ │ └── SystemSettings.js
│ │ └── db/
│ │ ├── index.js
│ │ ├── index.js
│ │ └── config.js
│ └── public/
│ └── uploads/
│ ├── products/
│ └── blog/ (NEW)
└── git/
├── fileStructure.txt
└── docker-compose.yml
│ ├── uploads/
│ │ ├── products/
│ │ └── blog/
│ ├── package-lock.json
│ ├── README.md
│ ├── .env
│ ├── package.json
│ ├── Dockerfile
│ └── .gitignore
└── db/
├── init/
│ ├── 18-email-templates.sql
│ ├── 01-schema.sql
│ ├── 02-seed.sql
│ ├── 16-blog-schema.sql
│ ├── 15-coupon.sql
│ ├── 17-product-reviews.sql
│ ├── 04-product-images.sql
│ ├── 09-system-settings.sql
│ ├── 14-product-notifications.sql
│ ├── 10-payment.sql
│ ├── 11-notifications.sql
│ ├── 12-shipping-orders.sql
│ ├── 08-create-email.sql
│ ├── 05-admin-role.sql
│ ├── 03-api-key.sql
│ ├── 07-user-keys.sql
│ ├── 13-cart-metadata.sql
│ └── 06-product-categories.sql
└── test/
├── fileStructure.txt
├── docker-compose.yml
└── .gitignore

File diff suppressed because it is too large Load diff

View file

@ -19,12 +19,12 @@
"@stripe/react-stripe-js": "^2.4.0",
"@stripe/stripe-js": "^2.2.0",
"@tanstack/react-query": "^5.12.2",
"@tanstack/react-query-devtools": "^5.12.2",
"axios": "^1.6.2",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-email-editor": "^1.7.11",
"react-redux": "^9.0.2",
"react-router-dom": "^6.20.1",
"recharts": "^2.10.3"

View file

@ -1,11 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import { Suspense, lazy } from 'react';
import { Suspense, lazy, useEffect } from 'react';
import { CircularProgress, Box } from '@mui/material';
import Notifications from './components/Notifications';
import ProtectedRoute from './components/ProtectedRoute';
import Notifications from '@components/Notifications';
import ProtectedRoute from '@components/ProtectedRoute';
import { StripeProvider } from './context/StripeContext';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
// Layouts
// Import layouts
import MainLayout from './layouts/MainLayout';
import AuthLayout from './layouts/AuthLayout';
import AdminLayout from './layouts/AdminLayout';
@ -40,7 +42,8 @@ const AdminBlogPage = lazy(() => import('@pages/Admin/BlogPage'));
const BlogEditPage = lazy(() => import('@pages/Admin/BlogEditPage'));
const AdminBlogCommentsPage = lazy(() => import('@pages/Admin/BlogCommentsPage'));
const AdminProductReviewsPage = lazy(() => import('@pages/Admin/ProductReviewsPage'));
const EmailTemplatesPage = lazy(() => import('@pages/Admin/EmailTemplatesPage'));
const BrandingPage = lazy(() => import('@pages/Admin/BrandingPage')); // New Branding Page
// Loading component for suspense fallback
const LoadingComponent = () => (
@ -50,6 +53,55 @@ const LoadingComponent = () => (
);
function App() {
// Use the centralized hook to fetch branding settings
const { data: brandingSettings, isLoading } = useBrandingSettings();
// Update the document head with branding settings
useEffect(() => {
if (brandingSettings) {
// Update document title
if (brandingSettings?.site_name) {
document.title = brandingSettings?.site_name;
}
// Update favicon if available
if (brandingSettings?.favicon_url) {
// Remove any existing favicon links
const existingLinks = document.querySelectorAll("link[rel*='icon']");
existingLinks.forEach(link => link.parentNode.removeChild(link));
// Create and add new favicon link
const link = document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = imageUtils.getImageUrl(brandingSettings?.favicon_url); //brandingSettings?.favicon_url;
document.head.appendChild(link);
// Also add Apple touch icon for iOS devices
const touchIcon = document.createElement('link');
touchIcon.rel = 'apple-touch-icon';
touchIcon.href = imageUtils.getImageUrl(brandingSettings?.favicon_url);
document.head.appendChild(touchIcon);
console.log(link);
}
// Add meta description if available
if (brandingSettings?.site_description) {
// Remove any existing description meta tags
const existingMeta = document.querySelector("meta[name='description']");
if (existingMeta) {
existingMeta.parentNode.removeChild(existingMeta);
}
// Create and add new meta description
const meta = document.createElement('meta');
meta.name = 'description';
meta.content = brandingSettings?.site_description;
document.head.appendChild(meta);
}
}
}, [brandingSettings]);
return (
<StripeProvider>
<Suspense fallback={<LoadingComponent />}>
@ -124,6 +176,8 @@ function App() {
<Route path="blog/:id" element={<BlogEditPage />} />
<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>
{/* Catch-all route for 404s */}

View file

@ -4,8 +4,15 @@ import FacebookIcon from '@mui/icons-material/Facebook';
import TwitterIcon from '@mui/icons-material/Twitter';
import InstagramIcon from '@mui/icons-material/Instagram';
import { Link as RouterLink } from 'react-router-dom';
import imageUtils from '@utils/imageUtils';
const Footer = () => {
const Footer = ({brandingSettings}) => {
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
const copyrightText = brandingSettings?.copyright_text ||
`© ${new Date().getFullYear()} ${siteName}. All rights reserved.`;
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
return (
<Box
component="footer"
@ -22,18 +29,33 @@ const Footer = () => {
<Container maxWidth="lg">
<Grid container spacing={3}>
<Grid item xs={12} sm={4}>
<Typography variant="h6" color="text.primary" gutterBottom>
Rocks, Bones & Sticks
</Typography>
{logoUrl ? (
<Box
component="img"
src={logoUrl}
alt={siteName}
sx={{
height: 40,
maxWidth: '100%',
mb: 2,
objectFit: 'contain'
}}
/>
) : (
<Typography variant="h6" color="text.primary" gutterBottom>
{siteName}
</Typography>
)}
<Typography variant="body2" color="text.secondary">
Your premier source for natural curiosities
and unique specimens from my backyards.
{ brandingSettings?.site_description || `Your premier source for natural curiosities
and unique specimens from around the world.`}
</Typography>
</Grid>
<Grid item xs={12} sm={4}>
<Typography variant="h6" color="text.primary" gutterBottom>
Quick Links
{brandingSettings?.site_quicklinks_title || `Quick Links`}
</Typography>
<Link component={RouterLink} to="/" color="inherit" display="block">
Home
@ -41,11 +63,14 @@ const Footer = () => {
<Link component={RouterLink} to="/products" color="inherit" display="block">
Shop All
</Link>
<Link component={RouterLink} to="/blog" color="inherit" display="block">
Blog
</Link>
</Grid>
<Grid item xs={12} sm={4}>
<Typography variant="h6" color="text.primary" gutterBottom>
Connect With Us
{brandingSettings?.site_connect || `Connect With Us`}
</Typography>
<Box>
<IconButton aria-label="facebook" color="primary">
@ -59,14 +84,14 @@ const Footer = () => {
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
Subscribe to our newsletter for updates on new items and promotions.
{brandingSettings?.site_main_newsletter_desc || `Subscribe to our newsletter for updates on new items and promotions.`}
</Typography>
</Grid>
</Grid>
<Box mt={3}>
<Typography variant="body2" color="text.secondary" align="center">
&copy; {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
{copyrightText}
</Typography>
</Box>
</Container>

View file

@ -10,8 +10,6 @@ import {
Card,
CardMedia,
CardActions,
FormControlLabel,
Checkbox,
Tooltip
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
@ -28,13 +26,15 @@ import imageUtils from '@utils/imageUtils';
* @param {Function} props.onChange - Callback when images change
* @param {boolean} props.multiple - Whether to allow multiple images
* @param {boolean} props.admin - Whether this is for admin use
* @param {string} props.inputId - Unique ID for the file input element
* @returns {JSX.Element} - Image uploader component
*/
const ImageUploader = ({
images = [],
onChange,
multiple = true,
admin = true
admin = true,
inputId = `image-upload-input-${Math.random().toString(36).substring(2, 9)}`
}) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
@ -129,18 +129,18 @@ const ImageUploader = ({
return (
<Box>
{/* Hidden file input */}
{/* Hidden file input with unique ID */}
<input
type="file"
multiple={multiple}
accept="image/*"
style={{ display: 'none' }}
id="image-upload-input"
id={inputId}
onChange={handleUpload}
/>
{/* Upload button */}
<label htmlFor="image-upload-input">
{/* Upload button with matching htmlFor */}
<label htmlFor={inputId}>
<Button
variant="outlined"
component="span"
@ -185,7 +185,7 @@ const ImageUploader = ({
component="img"
sx={{ height: 140, objectFit: 'cover' }}
image={imageUtils.getImageUrl(image.path)}
alt={`Product image ${index + 1}`}
alt={`Image ${index + 1}`}
/>
<CardActions sx={{ justifyContent: 'space-between', mt: 'auto' }}>
<Tooltip title={image.isPrimary ? "Primary Image" : "Set as Primary"}>

View file

@ -2,7 +2,7 @@ import { createSlice } from '@reduxjs/toolkit';
const initialState = {
notifications: [],
darkMode: !(localStorage.getItem('lightMode') === 'true'),
darkMode: (localStorage.getItem('darkMode') === 'true'),
mobileMenuOpen: false,
};
export const uiSlice = createSlice({
@ -25,11 +25,12 @@ export const uiSlice = createSlice({
},
toggleDarkMode: (state) => {
state.darkMode = !state.darkMode;
localStorage.setItem('lightMode', state.darkMode);
localStorage.setItem('darkMode', state.darkMode);
},
setDarkMode: (state, action) => {
state.darkMode = action.payload;
localStorage.setItem('lightMode', action.payload);
console.log('setDarkMode', action.payload, localStorage.getItem('darkMode'))
localStorage.setItem('darkMode', action.payload);
},
toggleMobileMenu: (state) => {
state.mobileMenuOpen = !state.mobileMenuOpen;

View file

@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import apiClient from '@services/api';
/**
* Custom hook for accessing branding settings
* Uses React Query's caching to prevent multiple redundant API calls
*
* @returns {Object} Query result with branding settings
*/
export const useBrandingSettings = () => {
return useQuery({
queryKey: ['branding-settings'],
queryFn: async () => {
try {
const response = await apiClient.get('/settings/branding');
// Convert the array of settings into an object for easier access
const settings = {};
if (Array.isArray(response.data)) {
response.data.forEach(setting => {
settings[setting.key] = setting.value;
});
} else {
// If response is already an object, use it directly
return response.data;
}
return settings;
} catch (error) {
console.error('Error fetching branding settings:', error);
return null;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes cache
});
};
export default useBrandingSettings;

View file

@ -0,0 +1,146 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import emailTemplateService from '@services/emailTemplateService';
import { useNotification } from './reduxHooks';
/**
* Custom hooks for email template management
*/
/**
* Hook for fetching all email templates
*/
export const useEmailTemplates = () => {
return useQuery({
queryKey: ['email-templates'],
queryFn: emailTemplateService.getAllTemplates
});
};
/**
* Hook for fetching templates by type
* @param {string} type - Template type
*/
export const useEmailTemplatesByType = (type) => {
return useQuery({
queryKey: ['email-templates', type],
queryFn: () => emailTemplateService.getTemplatesByType(type),
enabled: !!type
});
};
/**
* Hook for fetching a single template by ID
* @param {string} id - Template ID
*/
export const useEmailTemplate = (id) => {
return useQuery({
queryKey: ['email-template', id],
queryFn: () => emailTemplateService.getTemplateById(id),
enabled: !!id
});
};
/**
* Hook for fetching the default template for a type
* @param {string} type - Template type
*/
export const useDefaultEmailTemplate = (type) => {
return useQuery({
queryKey: ['default-email-template', type],
queryFn: () => emailTemplateService.getDefaultTemplate(type),
enabled: !!type
});
};
/**
* Hook for creating a new email template
*/
export const useCreateEmailTemplate = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (templateData) => emailTemplateService.createTemplate(templateData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
queryClient.invalidateQueries({ queryKey: ['email-templates', data.type] });
notification.showNotification('Email template created successfully', 'success');
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to create email template',
'error'
);
}
});
};
/**
* Hook for updating an email template
*/
export const useUpdateEmailTemplate = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ id, templateData }) => emailTemplateService.updateTemplate(id, templateData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
queryClient.invalidateQueries({ queryKey: ['email-templates', data.type] });
queryClient.invalidateQueries({ queryKey: ['email-template', data.id] });
notification.showNotification('Email template updated successfully', 'success');
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to update email template',
'error'
);
}
});
};
/**
* Hook for deleting an email template
*/
export const useDeleteEmailTemplate = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (id) => emailTemplateService.deleteTemplate(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
notification.showNotification('Email template deleted successfully', 'success');
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete email template',
'error'
);
}
});
};
/**
* Hook for setting a template as default
*/
export const useSetDefaultEmailTemplate = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (id) => emailTemplateService.setAsDefault(id),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
queryClient.invalidateQueries({ queryKey: ['email-templates', data.type] });
queryClient.invalidateQueries({ queryKey: ['default-email-template', data.type] });
notification.showNotification('Default template set successfully', 'success');
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to set default template',
'error'
);
}
});
};

View file

@ -22,9 +22,14 @@ import ClassIcon from '@mui/icons-material/Class';
import BookIcon from '@mui/icons-material/Book';
import CommentIcon from '@mui/icons-material/Comment';
import RateReviewIcon from '@mui/icons-material/RateReview';
import EmailIcon from '@mui/icons-material/Email';
import BrushIcon from '@mui/icons-material/Brush';
import { Link as RouterLink } from 'react-router-dom';
import { useAuth, useDarkMode } from '@hooks/reduxHooks';
import { useQuery } from '@tanstack/react-query';
import apiClient from '@services/api';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
const drawerWidth = 240;
@ -36,6 +41,15 @@ const AdminLayout = () => {
const { isAuthenticated, isAdmin, logout } = useAuth();
const [darkMode, toggleDarkMode] = useDarkMode();
const { data: brandingSettings } = useBrandingSettings();
// Get site name from branding settings or use default
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
// Get logo URL from branding settings
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
// Force drawer closed on mobile
useEffect(() => {
if (isMobile) {
@ -72,6 +86,8 @@ const AdminLayout = () => {
{ text: 'Coupons', icon: <LocalOfferIcon />, path: '/admin/coupons' },
{ 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: 'Branding', icon: <BrushIcon />, path: '/admin/branding' },
{ text: 'Settings', icon: <SettingsIcon />, path: '/admin/settings' },
{ text: 'Reports', icon: <BarChartIcon />, path: '/admin/reports' },
{ text: 'Product Reviews', icon: <RateReviewIcon />, path: '/admin/product-reviews' },
@ -124,7 +140,7 @@ const AdminLayout = () => {
noWrap
sx={{ flexGrow: 1 }}
>
Admin Dashboard
{siteName} Admin Dashboard
</Typography>
</Toolbar>
</AppBar>

View file

@ -3,8 +3,19 @@ import { Outlet } from 'react-router-dom';
import { Box, Container, Paper, Typography, Button } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
const AuthLayout = () => {
// Use the centralized hook to fetch branding settings
const { data: brandingSettings } = useBrandingSettings();
// Get site name and logo from branding settings
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
const copyrightText = brandingSettings?.copyright_text ||
`© ${new Date().getFullYear()} ${siteName}. All rights reserved.`;
return (
<Box
sx={{
@ -34,9 +45,23 @@ const AuthLayout = () => {
mb: 4,
}}
>
<Typography component="h1" variant="h4" gutterBottom>
Rocks, Bones & Sticks
</Typography>
{logoUrl ? (
<Box
component="img"
src={logoUrl}
alt={siteName}
sx={{
height: 60,
maxWidth: '100%',
mb: 3,
objectFit: 'contain'
}}
/>
) : (
<Typography component="h1" variant="h4" gutterBottom>
{siteName}
</Typography>
)}
<Box sx={{ width: '100%', mt: 2 }}>
<Outlet />
@ -58,7 +83,7 @@ const AuthLayout = () => {
>
<Container maxWidth="sm" sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
&copy; {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
{copyrightText}
</Typography>
</Container>
</Box>

View file

@ -19,6 +19,10 @@ import BookIcon from '@mui/icons-material/Book';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAuth, useCart, useDarkMode } from '../hooks/reduxHooks';
import Footer from '../components/Footer';
import { useQuery } from '@tanstack/react-query';
import apiClient from '@services/api';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
const MainLayout = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
@ -30,6 +34,11 @@ const MainLayout = () => {
const { itemCount } = useCart();
const [darkMode, toggleDarkMode] = useDarkMode();
const { data: brandingSettings } = useBrandingSettings();
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
const handleDrawerToggle = () => {
setDrawerOpen(!drawerOpen);
};
@ -70,9 +79,22 @@ const MainLayout = () => {
const drawer = (
<Box sx={{ width: 250 }} role="presentation" onClick={handleDrawerToggle}>
<Box sx={{ display: 'flex', p: 2, alignItems: 'center' }}>
<Typography variant="h6" component="div">
Rocks, Bones & Sticks
</Typography>
{logoUrl ? (
<Box
component="img"
src={logoUrl}
alt={siteName}
sx={{
height: 40,
maxWidth: '100%',
objectFit: 'contain'
}}
/>
) : (
<Typography variant="h6" component="div">
{siteName}
</Typography>
)}
</Box>
<Divider />
<List>
@ -135,19 +157,43 @@ const MainLayout = () => {
<MenuIcon />
</IconButton>
<Typography
variant="h6"
component={RouterLink}
to="/"
sx={{
flexGrow: 1,
color: 'white',
textDecoration: 'none',
display: { xs: 'none', sm: 'block' }
}}
>
Rocks, Bones & Sticks
</Typography>
{logoUrl ? (
<Box
component={RouterLink}
to="/"
sx={{
flexGrow: 1,
display: { xs: 'none', sm: 'flex' },
textDecoration: 'none',
color: 'white'
}}
>
<Box
component="img"
src={logoUrl}
alt={siteName}
sx={{
height: 40,
maxWidth: '200px',
objectFit: 'contain'
}}
/>
</Box>
) : (
<Typography
variant="h6"
component={RouterLink}
to="/"
sx={{
flexGrow: 1,
color: 'white',
textDecoration: 'none',
display: { xs: 'none', sm: 'block' }
}}
>
{siteName}
</Typography>
)}
{/* Desktop navigation */}
{!isMobile && (
@ -220,7 +266,7 @@ const MainLayout = () => {
</Container>
</Box>
<Footer />
<Footer brandingSettings={brandingSettings} />
</Box>
);
};

View file

@ -0,0 +1,945 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Grid,
TextField,
Button,
CircularProgress,
Alert,
Divider,
Tabs,
Tab,
Card,
CardContent,
FormControlLabel,
Switch,
IconButton,
InputAdornment,
Tooltip,
Accordion,
AccordionSummary,
AccordionDetails,
Snackbar,
} from '@mui/material';
import {
Save as SaveIcon,
Refresh as RefreshIcon,
Delete as DeleteIcon,
ColorLens as ColorLensIcon,
ExpandMore as ExpandMoreIcon,
} from '@mui/icons-material';
import { useAdminSettingsByCategory, useUpdateSetting, useUpdateSettings } from '../../hooks/settingsAdminHooks';
import ImageUploader from '@components/ImageUploader';
import { useQueryClient } from '@tanstack/react-query';
// Default theme colors to reset to
const DEFAULT_COLORS = {
light: {
primary: '#7e57c2', // deepPurple[400]
secondary: '#ffb300', // amber[500]
background: '#f5f5f5',
surface: '#ffffff',
text: '#000000',
},
dark: {
primary: '#9575cd', // deepPurple[300]
secondary: '#ffd54f', // amber[300]
background: '#212121',
surface: '#424242',
text: '#ffffff',
}
};
function ColorPickerInput({ label, value, onChange, defaultValue }) {
const handleChange = (e) => {
onChange(e.target.value);
};
const handleReset = () => {
onChange(defaultValue);
};
return (
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" sx={{ mr: 1 }}>
{label}
</Typography>
<Tooltip title="Reset to default">
<IconButton onClick={handleReset} size="small">
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<TextField
fullWidth
value={value}
onChange={handleChange}
type="color"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Box
sx={{
width: 20,
height: 20,
bgcolor: value,
borderRadius: '4px',
border: '1px solid #ddd'
}}
/>
</InputAdornment>
),
}}
/>
</Box>
</Box>
);
}
const BrandingPage = () => {
const [activeTab, setActiveTab] = useState(0);
const [logoImage, setLogoImage] = useState([]);
const [faviconImage, setFaviconImage] = useState([]);
const [notification, setNotification] = useState({ open: false, message: '', severity: 'success' });
// Get the React Query client for cache invalidation
const queryClient = useQueryClient();
// Fetch branding settings
const { data: brandingSettings, isLoading: settingsLoading, error: settingsError, refetch } = useAdminSettingsByCategory('branding');
const [formData, setFormData] = useState({
site_name: '',
site_main_page_title: '',
site_main_page_subtitle: '',
site_main_newsletter_desc: '',
site_main_bottom_sting: '',
site_quicklinks_title: '',
site_connect: '',
blog_title: '',
blog_desc: '',
blog_no_content_title: '',
blog_no_content_subtitle: '',
blog_search: '',
cart_empty: '',
cart_empty_subtitle: '',
product_title: '',
orders_title: '',
orders_empty: '',
site_description: '',
default_mode: 'light',
light_primary_color: DEFAULT_COLORS.light.primary,
light_secondary_color: DEFAULT_COLORS.light.secondary,
light_background_color: DEFAULT_COLORS.light.background,
light_surface_color: DEFAULT_COLORS.light.surface,
light_text_color: DEFAULT_COLORS.light.text,
dark_primary_color: DEFAULT_COLORS.dark.primary,
dark_secondary_color: DEFAULT_COLORS.dark.secondary,
dark_background_color: DEFAULT_COLORS.dark.background,
dark_surface_color: DEFAULT_COLORS.dark.surface,
dark_text_color: DEFAULT_COLORS.dark.text,
logo_url: '',
favicon_url: '',
copyright_text: '',
});
// Update settings mutation
const updateSettings = useUpdateSettings();
const updateSetting = useUpdateSetting();
// Handle tab change
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// Handle notification close
const handleCloseNotification = () => {
setNotification({ ...notification, open: false });
};
// Initialize form data when settings are loaded
useEffect(() => {
if (brandingSettings) {
const initialData = {};
brandingSettings?.forEach(setting => {
initialData[setting.key] = setting.value;
});
// Apply settings to form
setFormData(prev => ({
...prev,
...initialData
}));
if (initialData.logo_url) {
setLogoImage([{ path: initialData.logo_url, isPrimary: true }]);
} else {
setLogoImage([]);
}
if (initialData.favicon_url) {
setFaviconImage([{ path: initialData.favicon_url, isPrimary: true }]);
} else {
setFaviconImage([]);
}
}
}, [brandingSettings]);
// Handle form input changes
const handleChange = (e) => {
const { name, value, checked, type } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked.toString() : value
});
};
// Handle color picker changes
const handleColorChange = (name, value) => {
setFormData({
...formData,
[name]: value
});
};
// Handle logo image changes
const handleLogoChange = (images) => {
setLogoImage(images);
if (images && images.length > 0) {
setFormData({
...formData,
logo_url: images[0].path
});
} else {
setFormData({
...formData,
logo_url: ''
});
}
};
// Handle favicon image changes
const handleFaviconChange = (images) => {
setFaviconImage(images);
if (images && images.length > 0) {
setFormData({
...formData,
favicon_url: images[0].path
});
} else {
setFormData({
...formData,
favicon_url: ''
});
}
};
// Reset to default colors
const handleResetColors = (mode) => {
if (mode === 'light') {
setFormData({
...formData,
light_primary_color: DEFAULT_COLORS.light.primary,
light_secondary_color: DEFAULT_COLORS.light.secondary,
light_background_color: DEFAULT_COLORS.light.background,
light_surface_color: DEFAULT_COLORS.light.surface,
light_text_color: DEFAULT_COLORS.light.text,
});
} else {
setFormData({
...formData,
dark_primary_color: DEFAULT_COLORS.dark.primary,
dark_secondary_color: DEFAULT_COLORS.dark.secondary,
dark_background_color: DEFAULT_COLORS.dark.background,
dark_surface_color: DEFAULT_COLORS.dark.surface,
dark_text_color: DEFAULT_COLORS.dark.text,
});
}
};
// Save all settings
const handleSaveSettings = async () => {
const settingsToUpdate = [];
// Convert form data to settings array
Object.entries(formData).forEach(([key, value]) => {
if (value !== undefined) {
settingsToUpdate.push({
key,
value,
category: 'branding'
});
}
});
if (settingsToUpdate.length > 0) {
try {
await updateSettings.mutateAsync(settingsToUpdate);
// Show success notification
setNotification({
open: true,
message: 'Branding settings saved successfully',
severity: 'success'
});
// Invalidate the branding settings query to trigger a refresh in all components
queryClient.invalidateQueries(['branding-settings']);
// Refresh to get updated settings in this component
refetch();
// Update the favicon immediately if it was changed
if (formData.favicon_url) {
const link = document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = formData.favicon_url;
document.getElementsByTagName('head')[0].appendChild(link);
}
// Update document title if site name changed
if (formData.site_name) {
document.title = formData.site_name;
}
} catch (error) {
// Show error notification
setNotification({
open: true,
message: `Failed to save settings: ${error.message}`,
severity: 'error'
});
console.error('Failed to update settings:', error);
}
}
};
// Loading state
if (settingsLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (settingsError) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading branding settings: {settingsError.message}
</Alert>
);
}
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Branding & Theme Settings
</Typography>
<Paper sx={{ mb: 4 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
aria-label="branding tabs"
>
<Tab label="General" id="branding-tab-0" />
<Tab label="Colors & Theme" id="branding-tab-1" />
<Tab label="Assets" id="branding-tab-2" />
</Tabs>
</Box>
{/* General Settings Tab */}
<TabPanel value={activeTab} index={0}>
<Typography variant="h6" gutterBottom>
General Settings
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Site Name"
name="site_name"
value={formData.site_name || ''}
onChange={handleChange}
helperText="The name of your site (e.g., Rocks, Bones & Sticks)"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={formData.default_mode === 'dark'}
onChange={(e) => setFormData({
...formData,
default_mode: e.target.checked ? 'dark' : 'light'
})}
name="default_mode"
color="primary"
/>
}
label="Use Dark Mode by Default"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Site Description"
name="site_description"
multiline
rows={2}
value={formData.site_description || ''}
onChange={handleChange}
helperText="Short description for SEO and social media"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Copyright Text"
name="copyright_text"
value={formData.copyright_text || ''}
onChange={handleChange}
helperText="Text to display in the footer (e.g., © 2025 Your Company Name. All rights reserved.)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Main Banner Title"
name="site_main_page_title"
value={formData.site_main_page_title || ''}
onChange={handleChange}
helperText="Text to display in the Banner Title (e.g., Discover Natural Wonders)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Main Banner Subtitle"
name="site_main_page_subtitle"
value={formData.site_main_page_subtitle || ''}
onChange={handleChange}
helperText="Text to display in the Banner Subtitle (e.g., Unique rocks, bones, and sticks from around the world)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Newletter Description"
name="site_main_newsletter_desc"
value={formData.site_main_newsletter_desc || ''}
onChange={handleChange}
helperText="Text to display in the Footer (e.g., Subscribe to our newsletter for updates on new items and promotions)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Banner Bottom"
name="site_main_bottom_sting"
value={formData.site_main_bottom_sting || ''}
onChange={handleChange}
helperText="Text to display in the Bottom Banner (e.g., Ready to explore more?)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Site Description"
name="site_description"
value={formData.site_description || ''}
onChange={handleChange}
helperText="Text For the Site description (e.g., Your premier source for natural curiosities and unique specimens)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Quick Links Title"
name="site_quicklinks_title"
value={formData.site_quicklinks_title || ''}
onChange={handleChange}
helperText="Text For Quick links (e.g., Quick Links)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Connect us Title"
name="site_connect"
value={formData.site_connect || ''}
onChange={handleChange}
helperText="Text For Connect with us section(e.g., Connect with us )"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Blog Title"
name="blog_title"
value={formData.blog_title || ''}
onChange={handleChange}
helperText="Text For Blog Title(e.g., Our Story )"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Blog Description"
name="blog_desc"
value={formData.blog_desc || ''}
onChange={handleChange}
helperText="Text For Blog Description (e.g., Discover insights about our natural collections, sourcing adventures, and unique specimens )"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Blog No Content Title"
name="blog_no_content_title"
value={formData.blog_no_content_title || ''}
onChange={handleChange}
helperText="Text For no found Blog content (e.g., No blog posts found )"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Blog No Content Subtitle"
name="blog_no_content_subtitle"
value={formData.blog_no_content_subtitle || ''}
onChange={handleChange}
helperText="Subtitle For no found Blog content (e.g., Check back soon for new content)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Blog Search Text"
name="blog_search"
value={formData.blog_search || ''}
onChange={handleChange}
helperText="Search text Blog Page (e.g., Search blog posts)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Cart Empty Text"
name="cart_empty"
value={formData.cart_empty || ''}
onChange={handleChange}
helperText="Text for empty cart (e.g., Your Cart is Empty)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Cart Empty Subtitle"
name="cart_empty_subtitle"
value={formData.cart_empty_subtitle || ''}
onChange={handleChange}
helperText="Main Text for empty cart (e.g., Looks like you have not added any items to your cart yet.)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Product Page Title"
name="product_title"
value={formData.product_title || ''}
onChange={handleChange}
helperText="Text for Produce page title (e.g., Products)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Orders Page Title"
name="orders_title"
value={formData.orders_title || ''}
onChange={handleChange}
helperText="Text for Orders page title (e.g., My Orders)"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Orders No Content"
name="orders_empty"
value={formData.orders_empty || ''}
onChange={handleChange}
helperText="Text for Orders page no content (e.g., You have not placed any orders yet.)"
/>
</Grid>
</Grid>
</TabPanel>
{/* Colors & Theme Tab */}
<TabPanel value={activeTab} index={1}>
<Grid container spacing={4}>
{/* Light Mode Colors */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Light Mode Colors</Typography>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={() => handleResetColors('light')}
>
Reset to Default
</Button>
</Box>
<ColorPickerInput
label="Primary Color"
value={formData.light_primary_color || DEFAULT_COLORS.light.primary}
onChange={(value) => handleColorChange('light_primary_color', value)}
defaultValue={DEFAULT_COLORS.light.primary}
/>
<ColorPickerInput
label="Secondary Color"
value={formData.light_secondary_color || DEFAULT_COLORS.light.secondary}
onChange={(value) => handleColorChange('light_secondary_color', value)}
defaultValue={DEFAULT_COLORS.light.secondary}
/>
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>Advanced Colors</Typography>
</AccordionSummary>
<AccordionDetails>
<ColorPickerInput
label="Background Color"
value={formData.light_background_color || DEFAULT_COLORS.light.background}
onChange={(value) => handleColorChange('light_background_color', value)}
defaultValue={DEFAULT_COLORS.light.background}
/>
<ColorPickerInput
label="Surface Color"
value={formData.light_surface_color || DEFAULT_COLORS.light.surface}
onChange={(value) => handleColorChange('light_surface_color', value)}
defaultValue={DEFAULT_COLORS.light.surface}
/>
<ColorPickerInput
label="Text Color"
value={formData.light_text_color || DEFAULT_COLORS.light.text}
onChange={(value) => handleColorChange('light_text_color', value)}
defaultValue={DEFAULT_COLORS.light.text}
/>
</AccordionDetails>
</Accordion>
<Box sx={{ bgcolor: formData.light_background_color, p: 3, borderRadius: 1, border: '1px solid #ddd' }}>
<Typography variant="h6" sx={{ color: formData.light_text_color }}>Preview</Typography>
<Box sx={{
display: 'flex',
justifyContent: 'center',
mt: 2,
gap: 2
}}>
<Button
variant="contained"
sx={{
bgcolor: formData.light_primary_color,
'&:hover': {
bgcolor: formData.light_primary_color,
filter: 'brightness(0.9)'
}
}}
>
Primary
</Button>
<Button
variant="contained"
sx={{
bgcolor: formData.light_secondary_color,
'&:hover': {
bgcolor: formData.light_secondary_color,
filter: 'brightness(0.9)'
}
}}
>
Secondary
</Button>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
{/* Dark Mode Colors */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Dark Mode Colors</Typography>
<Button
size="small"
startIcon={<RefreshIcon />}
onClick={() => handleResetColors('dark')}
>
Reset to Default
</Button>
</Box>
<ColorPickerInput
label="Primary Color"
value={formData.dark_primary_color || DEFAULT_COLORS.dark.primary}
onChange={(value) => handleColorChange('dark_primary_color', value)}
defaultValue={DEFAULT_COLORS.dark.primary}
/>
<ColorPickerInput
label="Secondary Color"
value={formData.dark_secondary_color || DEFAULT_COLORS.dark.secondary}
onChange={(value) => handleColorChange('dark_secondary_color', value)}
defaultValue={DEFAULT_COLORS.dark.secondary}
/>
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>Advanced Colors</Typography>
</AccordionSummary>
<AccordionDetails>
<ColorPickerInput
label="Background Color"
value={formData.dark_background_color || DEFAULT_COLORS.dark.background}
onChange={(value) => handleColorChange('dark_background_color', value)}
defaultValue={DEFAULT_COLORS.dark.background}
/>
<ColorPickerInput
label="Surface Color"
value={formData.dark_surface_color || DEFAULT_COLORS.dark.surface}
onChange={(value) => handleColorChange('dark_surface_color', value)}
defaultValue={DEFAULT_COLORS.dark.surface}
/>
<ColorPickerInput
label="Text Color"
value={formData.dark_text_color || DEFAULT_COLORS.dark.text}
onChange={(value) => handleColorChange('dark_text_color', value)}
defaultValue={DEFAULT_COLORS.dark.text}
/>
</AccordionDetails>
</Accordion>
<Box sx={{ bgcolor: formData.dark_background_color, p: 3, borderRadius: 1, border: '1px solid #333' }}>
<Typography variant="h6" sx={{ color: formData.dark_text_color }}>Preview</Typography>
<Box sx={{
display: 'flex',
justifyContent: 'center',
mt: 2,
gap: 2
}}>
<Button
variant="contained"
sx={{
bgcolor: formData.dark_primary_color,
'&:hover': {
bgcolor: formData.dark_primary_color,
filter: 'brightness(0.9)'
}
}}
>
Primary
</Button>
<Button
variant="contained"
sx={{
bgcolor: formData.dark_secondary_color,
'&:hover': {
bgcolor: formData.dark_secondary_color,
filter: 'brightness(0.9)'
}
}}
>
Secondary
</Button>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
</TabPanel>
{/* Assets Tab */}
<TabPanel value={activeTab} index={2}>
<Grid container spacing={3}>
{/* Favicon Upload */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>
Favicon
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Upload a favicon for your site. This will appear in browser tabs. Recommended size: 32px by 32px.
</Typography>
<ImageUploader
images={faviconImage}
onChange={handleFaviconChange}
multiple={false}
/>
{faviconImage.length > 0 && (
<Box sx={{ mt: 2, p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Current Favicon
</Typography>
<Box
component="img"
src={faviconImage[0].path}
alt="Current favicon"
sx={{
width: '32px',
height: '32px',
display: 'block'
}}
/>
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Logo Upload */}
<Grid item xs={12} md={6}>
<Card variant="outlined">
<CardContent>
<Typography variant="h6" gutterBottom>
Logo
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Upload your site logo. Recommended size: 200px by 50px.
</Typography>
<ImageUploader
images={logoImage}
onChange={handleLogoChange}
multiple={false}
/>
{logoImage.length > 0 && (
<Box sx={{ mt: 2, p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Current Logo
</Typography>
<Box
component="img"
src={logoImage[0].path}
alt="Current logo"
sx={{
maxWidth: '100%',
height: 'auto',
maxHeight: '100px',
display: 'block'
}}
/>
</Box>
)}
</CardContent>
</Card>
</Grid>
</Grid>
</TabPanel>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 2 }}>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={refetch}
sx={{ mr: 2 }}
>
Refresh
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={handleSaveSettings}
disabled={updateSettings.isLoading}
>
{updateSettings.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
</Button>
</Box>
</Paper>
{/* Notification */}
<Snackbar
open={notification.open}
autoHideDuration={6000}
onClose={handleCloseNotification}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={handleCloseNotification}
severity={notification.severity}
sx={{ width: '100%' }}
>
{notification.message}
</Alert>
</Snackbar>
</Box>
);
};
// TabPanel component
function TabPanel(props) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`branding-tabpanel-${index}`}
aria-labelledby={`branding-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 3 }}>
{children}
</Box>
)}
</div>
);
}
export default BrandingPage;

View file

@ -0,0 +1,660 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
TextField,
Button,
Grid,
Divider,
CircularProgress,
Alert,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Accordion,
AccordionSummary,
AccordionDetails,
List,
ListItem,
ListItemText,
Chip,
Tooltip,
Card,
CardContent
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
Save as SaveIcon,
Visibility as PreviewIcon,
ExpandMore as ExpandMoreIcon,
FormatBold as BoldIcon,
FormatItalic as FormatItalicIcon,
FormatListBulleted as BulletListIcon,
FormatListNumbered as NumberedListIcon,
Link as LinkIcon,
Title as TitleIcon,
Info as InfoIcon
} from '@mui/icons-material';
import EmailEditor from 'react-email-editor';
import { useAdminSettingsByCategory, useDeleteSetting, useUpdateSetting } from '../../hooks/settingsAdminHooks';
// Available email template types
const EMAIL_TYPES = [
{ id: 'login_code', name: 'Login Code', description: 'Sent when a user requests a login code' },
{ id: 'shipping_notification', name: 'Shipping Notification', description: 'Sent when an order is shipped' },
{ id: 'order_confirmation', name: 'Order Confirmation', description: 'Sent when an order is placed' },
{ id: 'low_stock_alert', name: 'Low Stock Alert', description: 'Sent when product stock falls below threshold' },
{ id: 'welcome_email', name: 'Welcome Email', description: 'Sent when a user registers for the first time' },
{ id: 'custom', name: 'Custom Template', description: 'A custom email template for any purpose' }
];
// Template variable placeholders for each email type
const TEMPLATE_VARIABLES = {
login_code: [
{ key: '{{code}}', description: 'The login verification code' },
{ key: '{{loginLink}}', description: 'Direct login link with the code' },
{ key: '{{email}}', description: 'User\'s email address' }
],
shipping_notification: [
{ key: '{{first_name}}', description: 'Customer\'s first name' },
{ key: '{{order_id}}', description: 'Order identifier' },
{ key: '{{tracking_number}}', description: 'Shipping tracking number' },
{ key: '{{carrier}}', description: 'Shipping carrier name' },
{ key: '{{tracking_link}}', description: 'Link to track the package' },
{ key: '{{shipped_date}}', description: 'Date the order was shipped' },
{ key: '{{estimated_delivery}}', description: 'Estimated delivery date/time' },
{ key: '{{items_html}}', description: 'HTML table of ordered items' },
{ key: '{{customer_message}}', description: 'Optional message from staff' }
],
order_confirmation: [
{ key: '{{first_name}}', description: 'Customer\'s first name' },
{ key: '{{order_id}}', description: 'Order identifier' },
{ key: '{{order_date}}', description: 'Date the order was placed' },
{ key: '{{order_total}}', description: 'Total amount of the order' },
{ key: '{{shipping_address}}', description: 'Shipping address' },
{ key: '{{items_html}}', description: 'HTML table of ordered items' }
],
low_stock_alert: [
{ key: '{{product_name}}', description: 'Name of the product low in stock' },
{ key: '{{current_stock}}', description: 'Current stock quantity' },
{ key: '{{threshold}}', description: 'Low stock threshold' }
],
welcome_email: [
{ key: '{{first_name}}', description: 'User\'s first name' },
{ key: '{{email}}', description: 'User\'s email address' }
],
custom: [] // Custom templates might have any variables
};
// Sample placeholder data for preview
const PREVIEW_DATA = {
login_code: {
code: '123456',
loginLink: 'https://example.com/verify?code=123456&email=user@example.com',
email: 'user@example.com'
},
shipping_notification: {
first_name: 'Jane',
order_id: 'ORD-1234567',
tracking_number: 'TRK123456789',
carrier: 'FedEx',
tracking_link: 'https://www.fedex.com/track?123456789',
shipped_date: '2025-04-29',
estimated_delivery: '2-3 business days',
items_html: `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Amethyst Geode</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">1</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$49.99</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$49.99</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Driftwood Piece</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">2</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$14.99</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$29.98</td>
</tr>
`,
customer_message: 'Thank you for your order! We packaged it with extra care.'
},
order_confirmation: {
first_name: 'John',
order_id: 'ORD-9876543',
order_date: '2025-04-29',
order_total: '$94.97',
shipping_address: '123 Main St, Anytown, CA 12345',
items_html: `
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Polished Labradorite</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">1</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$29.99</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$29.99</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #eee;">Fossil Fish</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">1</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$64.98</td>
<td style="padding: 10px; border-bottom: 1px solid #eee;">$64.98</td>
</tr>
`
},
low_stock_alert: {
product_name: 'Amethyst Geode',
current_stock: '2',
threshold: '5'
},
welcome_email: {
first_name: 'Emily',
email: 'emily@example.com'
},
custom: {}
};
// Default templates
const DEFAULT_TEMPLATES = {
login_code: {
// Simplified template structure for React Email Editor
body: {
rows: [
{
cells: [1],
columns: [
{
contents: [
{
type: "text",
values: {
containerPadding: "10px",
textAlign: "left",
text: "<h1>Your login code is: {{code}}</h1><p>This code will expire in 15 minutes.</p><p>Or click <a href=\"{{loginLink}}\">here</a> to log in directly.</p>"
}
}
]
}
]
}
]
}
},
shipping_notification: {
// Simplified template - the actual structure would be more complex in the real editor
body: {
rows: [
{
cells: [1],
columns: [
{
contents: [
{
type: "text",
values: {
containerPadding: "10px",
textAlign: "center",
text: "<h1>Your Order Has Shipped!</h1><p>Order #{{order_id}}</p>"
}
}
]
}
]
},
{
cells: [1],
columns: [
{
contents: [
{
type: "text",
values: {
containerPadding: "10px",
textAlign: "left",
text: "<p>Hello {{first_name}},</p><p>Good news! Your order has been shipped and is on its way to you.</p>"
}
}
]
}
]
}
]
}
},
welcome_email: {
// Simplified template
body: {
rows: [
{
cells: [1],
columns: [
{
contents: [
{
type: "text",
values: {
containerPadding: "10px",
textAlign: "center",
text: "<h1>Welcome to Rocks, Bones & Sticks!</h1>"
}
}
]
}
]
},
{
cells: [1],
columns: [
{
contents: [
{
type: "text",
values: {
containerPadding: "10px",
textAlign: "left",
text: "<p>Hello {{first_name}},</p><p>Thank you for creating an account with us. We're excited to have you join our community!</p>"
}
}
]
}
]
}
]
}
}
};
const EmailTemplatesPage = () => {
const [activeTab, setActiveTab] = useState(0);
const [editingTemplate, setEditingTemplate] = useState(null);
const [templateList, setTemplateList] = useState([]);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const emailEditorRef = useRef(null);
const { data: emailSettings, isLoading, error } = useAdminSettingsByCategory('email_templates');
const deleteSettingMutation = useDeleteSetting();
const updateSetting = useUpdateSetting();
useEffect(() => {
if (emailSettings) {
const templates = emailSettings.map(setting => {
try {
const templateData = JSON.parse(setting.value);
return { id: setting.key, ...templateData };
} catch {
return null;
}
}).filter(Boolean);
setTemplateList(templates);
}
}, [emailSettings]);
const handleTabChange = (e, newValue) => {
// Only allow tab switching if not currently editing a template
if (!editingTemplate) {
setActiveTab(newValue);
}
};
const handleEditTemplate = (template) => {
setEditingTemplate({ ...template });
// If the email editor is loaded, set its design
if (emailEditorRef.current && template.design) {
setTimeout(() => {
emailEditorRef.current.editor.loadDesign(template.design);
}, 500);
}
};
const handleSaveTemplate = async () => {
if (!editingTemplate || !emailEditorRef.current) return;
try {
// Save the design from the email editor
emailEditorRef.current.editor.exportHtml(async (data) => {
const { design, html } = data;
// Update the template with the new design and HTML
const updatedTemplate = {
...editingTemplate,
design: design, // Store the design JSON for future editing
content: html, // Store the generated HTML for rendering
updatedAt: new Date().toISOString()
};
await updateSetting.mutateAsync({
key: updatedTemplate.id,
value: JSON.stringify(updatedTemplate),
category: 'email_templates'
});
setTemplateList(prev => prev.map(t => t.id === updatedTemplate.id ? updatedTemplate : t));
setEditingTemplate(null);
});
} catch (error) {
console.error('Failed to save template:', error);
}
};
const handlePreviewTemplate = (template) => {
if (template.content) {
setPreviewContent(`
<div style="max-width:600px;margin:0 auto;border:1px solid #ccc">
<div style="padding:10px;background:#f5f5f5;font-weight:bold">Subject: ${template.subject}</div>
${template.content}
</div>
`);
setPreviewDialogOpen(true);
} else 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: ${template.subject}</div>
${html}
</div>
`);
setPreviewDialogOpen(true);
});
}
};
const handleCreateTemplate = () => {
const templateType = EMAIL_TYPES[activeTab === 0 ? 5 : activeTab - 1].id;
const templateName = EMAIL_TYPES[activeTab === 0 ? 5 : activeTab - 1].name;
// Create default HTML content for the new template
let defaultContent = `<h1>Your ${templateName}</h1><p>Start editing this template to customize it for your needs.</p>`;
// Add sample placeholders based on template type
if (templateType === 'login_code') {
defaultContent = `<h1>Your login code is: {{code}}</h1>
<p>This code will expire in 15 minutes.</p>
<p>Or click <a href="{{loginLink}}">here</a> to log in directly.</p>`;
} else if (templateType === 'shipping_notification') {
defaultContent = `<h1>Your Order Has Shipped!</h1>
<p>Hello {{first_name}},</p>
<p>Good news! Your order #{{order_id}} has been shipped and is on its way to you.</p>`;
} else if (templateType === 'welcome_email') {
defaultContent = `<h1>Welcome to Rocks, Bones & Sticks!</h1>
<p>Hello {{first_name}},</p>
<p>Thank you for creating an account with us. We're excited to have you join our community!</p>`;
}
const newTemplate = {
id: `email_template_${Date.now()}`,
name: `New ${templateName}`,
type: templateType,
subject: `Your ${templateName}`,
content: defaultContent,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
setEditingTemplate(newTemplate);
};
const onEditorReady = () => {
// You can perform any setup actions here when the editor is loaded
console.log('Email editor is ready');
// If there's a template being edited, load its design
if (editingTemplate?.design && emailEditorRef.current) {
emailEditorRef.current.editor.loadDesign(editingTemplate.design);
}
// If there's no design but we have HTML content, create a default design with that content
else if (editingTemplate?.content && emailEditorRef.current) {
const defaultDesign = {
body: {
rows: [
{
cells: [1],
columns: [
{
contents: [
{
type: "html",
values: {
html: editingTemplate.content,
containerPadding: "10px"
}
}
]
}
]
}
]
}
};
emailEditorRef.current.editor.loadDesign(defaultDesign);
}
// If it's a new template with no design or content, load the default template
else if (editingTemplate && emailEditorRef.current) {
// Try to load a default template for the template type
const defaultTemplate = DEFAULT_TEMPLATES[editingTemplate.type] || {
body: {
rows: [
{
cells: [1],
columns: [
{
contents: [
{
type: "html",
values: {
html: "<h1>Your " + editingTemplate.name + "</h1><p>Start editing your email template here.</p>",
containerPadding: "10px"
}
}
]
}
]
}
]
}
};
emailEditorRef.current.editor.loadDesign(defaultTemplate);
}
};
if (isLoading) return <Box textAlign="center" py={5}><CircularProgress /></Box>;
if (error) return <Alert severity="error">Error loading templates.</Alert>;
let first_name = "{{first_name}}"
return (
<Box>
<Typography variant="h4" mb={2}>Email Templates</Typography>
<Paper sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 2 }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
disabled={!!editingTemplate}
>
<Tab label="All" />
{EMAIL_TYPES.map(type => <Tab key={type.id} label={type.name} />)}
</Tabs>
{activeTab > 0 && (
<Button
startIcon={<AddIcon />}
color="primary"
variant="contained"
onClick={handleCreateTemplate}
sx={{ my: 1 }}
disabled={!!editingTemplate}
>
Create {EMAIL_TYPES[activeTab - 1]?.name} Template
</Button>
)}
</Box>
</Paper>
{!editingTemplate ? (
<Grid container spacing={2}>
{templateList.length > 0 ? (
templateList
.filter(template => activeTab === 0 || template.type === EMAIL_TYPES[activeTab - 1]?.id)
.map(template => (
<Grid item xs={12} md={6} key={template.id}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">{template.name}</Typography>
<Chip
label={EMAIL_TYPES.find(t => t.id === template.type)?.name || 'Unknown'}
size="small"
color="primary"
variant="outlined"
/>
</Box>
<Typography variant="caption" display="block" color="text.secondary" gutterBottom>
Last updated: {new Date(template.updatedAt).toLocaleString()}
</Typography>
<Typography variant="body2" color="text.secondary">
Subject: {template.subject}
</Typography>
<Box mt={2} display="flex" gap={1}>
<Button
onClick={() => handleEditTemplate(template)}
startIcon={<EditIcon />}
variant="outlined"
size="small"
>
Edit
</Button>
<Button
onClick={() => handlePreviewTemplate(template)}
startIcon={<PreviewIcon />}
variant="outlined"
size="small"
>
Preview
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))
) : (
<Grid item xs={12}>
<Alert severity="info">
No email templates found. Click "Create Template" to create your first template.
</Alert>
</Grid>
)}
</Grid>
) : (
// Edit view
<Box mb={3}>
<Paper sx={{ p: 2, mb: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Template Name"
value={editingTemplate?.name || ''}
onChange={(e) => setEditingTemplate(prev => ({ ...prev, name: e.target.value }))}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Subject Line"
value={editingTemplate?.subject || ''}
onChange={(e) => setEditingTemplate(prev => ({ ...prev, subject: e.target.value }))}
/>
</Grid>
</Grid>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="subtitle1" gutterBottom sx={{ mr: 1 }}>Email Content</Typography>
</Box>
<Alert severity="info" sx={{ mb: 3 }}>
<Box>
<Typography variant="subtitle1" fontWeight="medium">Tips for creating effective email templates:</Typography>
<ol>
<li>For best results, design your emails visually in Figma first</li>
<li>Export your design as HTML or use an email-specific design tool</li>
<li>Copy the HTML into an HTML block in this editor</li>
<li>Add dynamic variables like {`{{first_name}}`} as text where needed</li>
<li>Reference "Available Template Variables" below this tip</li>
</ol>
</Box>
</Alert>
<Accordion sx={{ mb: 2 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>Available Template Variables</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2" gutterBottom>
You can use these variables in your message. They will be replaced when the email is sent.
</Typography>
<List dense>
{TEMPLATE_VARIABLES[editingTemplate.type]?.map(variable => (
<ListItem key={variable.key}>
<ListItemText
primary={variable.key}
secondary={variable.description}
/>
</ListItem>
))}
</List>
</AccordionDetails>
</Accordion>
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button onClick={() => setEditingTemplate(null)}>Cancel</Button>
<Button
onClick={() => handlePreviewTemplate(editingTemplate)}
startIcon={<PreviewIcon />}
>
Preview
</Button>
<Button
onClick={handleSaveTemplate}
startIcon={<SaveIcon />}
variant="contained"
disabled={updateSetting.isLoading}
>
{updateSetting.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
</Button>
</Box>
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, height: '600px' }}>
<EmailEditor
ref={emailEditorRef}
onReady={onEditorReady}
minHeight="600px"
/>
</Box>
</Paper>
</Box>
)}
{/* Preview Dialog */}
<Dialog open={previewDialogOpen} onClose={() => setPreviewDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>Email Preview</DialogTitle>
<DialogContent>
<Box dangerouslySetInnerHTML={{ __html: previewContent }} />
</DialogContent>
<DialogActions>
<Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default EmailTemplatesPage;

View file

@ -47,6 +47,8 @@ import apiClient from '@services/api';
import { format } from 'date-fns';
import ProductImage from '@components/ProductImage';
import OrderStatusDialog from '@components/OrderStatusDialog';
import useBrandingSettings from '@hooks/brandingHooks';
const { data: brandingSettings } = useBrandingSettings();
const AdminOrdersPage = () => {
const [page, setPage] = useState(0);
@ -56,6 +58,7 @@ const AdminOrdersPage = () => {
const [selectedOrder, setSelectedOrder] = useState(null);
const [orderDetails, setOrderDetails] = useState(null);
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
const { data: brandingSettings } = useBrandingSettings();
const [newStatus, setNewStatus] = useState('');
const queryClient = useQueryClient();

View file

@ -3,10 +3,12 @@ import { Box, Typography, Button, Grid, Card, CardMedia, CardContent, Container
import { Link as RouterLink } from 'react-router-dom';
import { useProducts, useCategories } from '@hooks/apiHooks';
import imageUtils from '@utils/imageUtils';
import useBrandingSettings from '@hooks/brandingHooks';
const HomePage = () => {
const { data: products, isLoading: productsLoading } = useProducts({ limit: 6 });
const { data: categories, isLoading: categoriesLoading } = useCategories();
const { data: brandingSettings } = useBrandingSettings();
return (
<Box>
@ -25,10 +27,10 @@ const HomePage = () => {
>
<Container maxWidth="md">
<Typography variant="h2" component="h1" gutterBottom>
Discover Natural Wonders
{brandingSettings?.site_main_page_title || `Discover Natural Wonders`}
</Typography>
<Typography variant="h5" paragraph>
Unique rocks, bones, and sticks from around my backyards
{brandingSettings?.site_main_page_subtitle || `Unique rocks, bones, and sticks from around my backyards`}
</Typography>
<Button
variant="contained"
@ -89,7 +91,7 @@ const HomePage = () => {
{/* Featured Products Section */}
<Typography variant="h4" component="h2" gutterBottom>
Featured Products
Featured {brandingSettings?.product_title || `Products`}
</Typography>
{!productsLoading && products && (
<Grid container spacing={3}>
@ -148,7 +150,7 @@ const HomePage = () => {
}}
>
<Typography variant="h4" component="h2" gutterBottom>
Ready to explore more?
{brandingSettings?.site_main_bottom_sting || `Ready to explore more?`}
</Typography>
<Button
variant="contained"
@ -158,7 +160,7 @@ const HomePage = () => {
to="/products"
sx={{ mt: 2 }}
>
View All Products
View All {brandingSettings?.product_title || `Products`}
</Button>
</Box>
</Box>

View file

@ -35,12 +35,14 @@ import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHoo
import ProductRatingDisplay from '@components/ProductRatingDisplay';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
import useBrandingSettings from '@hooks/brandingHooks';
const ProductsPage = () => {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { isAuthenticated, user } = useAuth();
const { data: brandingSettings } = useBrandingSettings();
// Parse query params
const queryParams = new URLSearchParams(location.search);
@ -139,7 +141,7 @@ const ProductsPage = () => {
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Products
{brandingSettings?.product_title || `Products`}
</Typography>
{/* Search and filter bar */}
@ -156,7 +158,7 @@ const ProductsPage = () => {
<TextField
fullWidth
size="small"
placeholder="Search products..."
placeholder={`Search ${(brandingSettings?.product_title || `Products`).toLowerCase()}...`}
value={filters.search}
onChange={handleSearchChange}
InputProps={{

View file

@ -32,8 +32,11 @@ import { useNavigate } from 'react-router-dom';
import { useUserOrders, useUserOrder } from '../hooks/apiHooks';
import ProductImage from '../components/ProductImage';
import useBrandingSettings from '@hooks/brandingHooks';
const UserOrdersPage = () => {
const navigate = useNavigate();
const { data: brandingSettings } = useBrandingSettings();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [viewDialogOpen, setViewDialogOpen] = useState(false);
@ -127,7 +130,7 @@ const UserOrdersPage = () => {
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
My Orders
{brandingSettings?.orders_title || `My Orders`}
</Typography>
{/* Orders Table */}
@ -180,14 +183,15 @@ const UserOrdersPage = () => {
<TableRow>
<TableCell colSpan={6} align="center">
<Typography variant="body1" py={3}>
You haven't placed any orders yet.
{brandingSettings?.orders_empty || `You have not placed any orders`}
</Typography>
<Button
variant="contained"
onClick={() => navigate('/products')}
sx={{ mt: 2 }}
>
Browse Products
Browse
{` ${brandingSettings?.product_title || `Products`}`}
</Button>
</TableCell>
</TableRow>

View file

@ -0,0 +1,203 @@
import apiClient from './api';
/**
* Service for managing email templates
*/
const emailTemplateService = {
/**
* Get all email templates
* @returns {Promise<Array>} Array of email templates
*/
getAllTemplates: async () => {
try {
const response = await apiClient.get('/admin/settings/category/email_templates');
// Transform settings into template objects
return response.data.map(setting => {
try {
// Parse the template data from the JSON value
const templateData = JSON.parse(setting.value);
return {
id: setting.key,
...templateData
};
} catch (e) {
console.error(`Failed to parse template setting: ${setting.key}`, e);
return null;
}
}).filter(Boolean); // Remove any null entries
} catch (error) {
throw error.response?.data || { message: 'Failed to fetch email templates' };
}
},
/**
* Get a specific email template by ID
* @param {string} id - Template ID
* @returns {Promise<Object>} Email template object
*/
getTemplateById: async (id) => {
try {
const response = await apiClient.get(`/admin/settings/${id}`);
// Parse the template data from the JSON value
const templateData = JSON.parse(response.data.value);
return {
id: response.data.key,
...templateData
};
} catch (error) {
throw error.response?.data || { message: 'Failed to fetch email template' };
}
},
/**
* Get templates by type
* @param {string} type - Template type (e.g., 'login_code', 'shipping_notification')
* @returns {Promise<Array>} Array of email templates of the specified type
*/
getTemplatesByType: async (type) => {
try {
const templates = await emailTemplateService.getAllTemplates();
return templates.filter(template => template.type === type);
} catch (error) {
throw error;
}
},
/**
* Get the default template for a specific type
* @param {string} type - Template type
* @returns {Promise<Object|null>} Default email template for the type, or null if none exists
*/
getDefaultTemplate: async (type) => {
try {
const templates = await emailTemplateService.getTemplatesByType(type);
return templates.find(template => template.isDefault) || null;
} catch (error) {
throw error;
}
},
/**
* Create a new email template
* @param {Object} templateData - Template data
* @param {string} templateData.name - Template name
* @param {string} templateData.type - Template type
* @param {string} templateData.subject - Email subject line
* @param {string} templateData.content - HTML content of the email template
* @returns {Promise<Object>} Created email template
*/
createTemplate: async (templateData) => {
try {
// Generate a unique key for the setting
const templateKey = `email_template_${Date.now()}`;
// Create the template object
const template = {
name: templateData.name,
type: templateData.type,
subject: templateData.subject,
content: templateData.content,
isDefault: templateData.isDefault || false,
createdAt: new Date().toISOString()
};
// Save to settings
const response = await apiClient.put(`/admin/settings/${templateKey}`, {
value: JSON.stringify(template),
category: 'email_templates'
});
return {
id: templateKey,
...template
};
} catch (error) {
throw error.response?.data || { message: 'Failed to create email template' };
}
},
/**
* Update an existing email template
* @param {string} id - Template ID
* @param {Object} templateData - Updated template data
* @returns {Promise<Object>} Updated email template
*/
updateTemplate: async (id, templateData) => {
try {
// Create the updated template object
const template = {
name: templateData.name,
type: templateData.type,
subject: templateData.subject,
content: templateData.content,
isDefault: templateData.isDefault || false,
createdAt: templateData.createdAt,
updatedAt: new Date().toISOString()
};
// Update in settings
await apiClient.put(`/admin/settings/${id}`, {
value: JSON.stringify(template),
category: 'email_templates'
});
return {
id,
...template
};
} catch (error) {
throw error.response?.data || { message: 'Failed to update email template' };
}
},
/**
* Delete an email template
* @param {string} id - Template ID
* @returns {Promise<boolean>} Success status
*/
deleteTemplate: async (id) => {
try {
await apiClient.delete(`/admin/settings/${id}`);
return true;
} catch (error) {
throw error.response?.data || { message: 'Failed to delete email template' };
}
},
/**
* Set a template as the default for its type
* @param {string} id - Template ID
* @returns {Promise<Object>} Updated template
*/
setAsDefault: async (id) => {
try {
// Get the template to set as default
const template = await emailTemplateService.getTemplateById(id);
// Get all templates of the same type
const typeTemplates = await emailTemplateService.getTemplatesByType(template.type);
// For each template of the same type, unset default if it's set
for (const t of typeTemplates) {
if (t.id !== id && t.isDefault) {
await emailTemplateService.updateTemplate(t.id, {
...t,
isDefault: false
});
}
}
// Set the selected template as default
return await emailTemplateService.updateTemplate(id, {
...template,
isDefault: true
});
} catch (error) {
throw error.response?.data || { message: 'Failed to set template as default' };
}
}
};
export default emailTemplateService;

View file

@ -1,15 +1,191 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { useAppTheme } from './index';
import { createTheme } from '@mui/material/styles';
import { red, amber, grey, deepPurple } from '@mui/material/colors';
import { useQuery } from '@tanstack/react-query';
import apiClient from '@services/api';
import { useDarkMode } from '@hooks/reduxHooks';
import useBrandingSettings from '@hooks/brandingHooks';
/**
* Custom ThemeProvider that uses the app's theme with dark mode support
* This component should be used instead of the direct MUI ThemeProvider
* Custom ThemeProvider that uses branding settings for white labeling
*/
const ThemeProvider = ({ children }) => {
const theme = useAppTheme();
const [darkMode, _, setDarkMode] = useDarkMode();
const [theme, setTheme] = useState(null);
// Fetch branding settings
const { data: brandingSettings } = useBrandingSettings();
// Default colors
const defaultColors = {
light: {
primary: deepPurple[400],
secondary: amber[500],
error: red.A400,
background: '#f5f5f5',
paper: '#fff',
text: '#000000',
},
dark: {
primary: deepPurple[300],
secondary: amber[300],
error: red.A400,
background: grey[900],
paper: grey[800],
text: '#ffffff',
}
};
useEffect(() => {
// Only pull from default if user specified style hasn't been set
if(brandingSettings){
let isDarkMode = darkMode;
if(localStorage.getItem("darkMode") == null){
isDarkMode = brandingSettings?.default_mode === 'dark' ? true : darkMode;
setDarkMode(isDarkMode);
}
handleDarkMode(isDarkMode)
}
}, [brandingSettings, darkMode]);
function handleDarkMode(darkMode){
let isDarkMode = darkMode;
// Get colors based on mode
const mode = isDarkMode ? 'dark' : 'light';
const colors = {
primary: isDarkMode
? (brandingSettings?.dark_primary_color || defaultColors.dark.primary)
: (brandingSettings?.light_primary_color || defaultColors.light.primary),
secondary: isDarkMode
? (brandingSettings?.dark_secondary_color || defaultColors.dark.secondary)
: (brandingSettings?.light_secondary_color || defaultColors.light.secondary),
background: isDarkMode
? (brandingSettings?.dark_background_color || defaultColors.dark.background)
: (brandingSettings?.light_background_color || defaultColors.light.background),
paper: isDarkMode
? (brandingSettings?.dark_surface_color || defaultColors.dark.paper)
: (brandingSettings?.light_surface_color || defaultColors.light.paper),
text: isDarkMode
? (brandingSettings?.dark_text_color || defaultColors.dark.text)
: (brandingSettings?.light_text_color || defaultColors.light.text)
};
// Create theme
const newTheme = createTheme({
palette: {
mode,
primary: {
main: colors.primary,
},
secondary: {
main: colors.secondary,
},
error: {
main: defaultColors[mode].error,
},
background: {
default: colors.background,
paper: colors.paper,
},
text: {
primary: colors.text,
secondary: isDarkMode
? 'rgba(255, 255, 255, 0.7)'
: 'rgba(0, 0, 0, 0.6)',
},
},
typography: {
fontFamily: [
'Roboto',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Arial',
'sans-serif',
].join(','),
h1: {
fontSize: '2.5rem',
fontWeight: 500,
},
h2: {
fontSize: '2rem',
fontWeight: 500,
},
h3: {
fontSize: '1.75rem',
fontWeight: 500,
},
h4: {
fontSize: '1.5rem',
fontWeight: 500,
},
h5: {
fontSize: '1.25rem',
fontWeight: 500,
},
h6: {
fontSize: '1rem',
fontWeight: 500,
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 6,
textTransform: 'none',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 8,
boxShadow: isDarkMode
? '0 4px 20px rgba(0, 0, 0, 0.5)'
: '0 4px 20px rgba(0, 0, 0, 0.1)',
},
},
},
MuiAppBar: {
styleOverrides: {
root: {
boxShadow: isDarkMode
? '0 4px 20px rgba(0, 0, 0, 0.5)'
: '0 2px 10px rgba(0, 0, 0, 0.05)',
},
},
},
},
});
setTheme(newTheme);
}
// Use a default theme while loading
if (!theme) {
const defaultTheme = createTheme({
palette: {
mode: darkMode ? 'dark' : 'light',
primary: {
main: darkMode ? defaultColors.dark.primary : defaultColors.light.primary,
},
secondary: {
main: darkMode ? defaultColors.dark.secondary : defaultColors.light.secondary,
},
},
});
return (
<MuiThemeProvider theme={defaultTheme}>
<CssBaseline />
{children}
</MuiThemeProvider>
);
}
return (
<MuiThemeProvider theme={theme}>
<CssBaseline />