Blog Support, Whitelabel support better themeing
This commit is contained in:
parent
37da2acb5d
commit
f10ed6bf08
43 changed files with 5109 additions and 3475 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,4 +3,4 @@ node_modules
|
|||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
public/uploads/*
|
||||
backend/public/uploads/*
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ const fileFilter = (req, file, cb) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Create the multer instance
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
602
backend/src/routes/emailTemplatesAdmin.js
Normal file
602
backend/src/routes/emailTemplatesAdmin.js
Normal 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;
|
||||
};
|
||||
|
|
@ -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>© ${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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
52
backend/src/routes/publicSettings.js
Normal file
52
backend/src/routes/publicSettings.js
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
278
backend/src/services/emailService.js
Normal file
278
backend/src/services/emailService.js
Normal 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;
|
||||
|
|
@ -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>© ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
53
db/init/18-email-templates.sql
Normal file
53
db/init/18-email-templates.sql
Normal 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>© 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>© 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>© 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>© 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;
|
||||
49
db/init/19-branding-settings.sql
Normal file
49
db/init/19-branding-settings.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
4365
frontend/package-lock.json
generated
4365
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
© {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
|
||||
{copyrightText}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
38
frontend/src/hooks/brandingHooks.js
Normal file
38
frontend/src/hooks/brandingHooks.js
Normal 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;
|
||||
146
frontend/src/hooks/emailTemplateHooks.js
Normal file
146
frontend/src/hooks/emailTemplateHooks.js
Normal 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'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
© {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
|
||||
{copyrightText}
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
945
frontend/src/pages/Admin/BrandingPage.jsx
Normal file
945
frontend/src/pages/Admin/BrandingPage.jsx
Normal 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;
|
||||
660
frontend/src/pages/Admin/EmailTemplatesPage.jsx
Normal file
660
frontend/src/pages/Admin/EmailTemplatesPage.jsx
Normal 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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
203
frontend/src/services/emailTemplateService.js
Normal file
203
frontend/src/services/emailTemplateService.js
Normal 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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
Loading…
Reference in a new issue