Compare commits

...

2 commits

68 changed files with 10221 additions and 3454 deletions

2
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

65
db/init/15-coupon.sql Normal file
View file

@ -0,0 +1,65 @@
-- Create coupons table
CREATE TABLE coupons (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
code VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount'
discount_value DECIMAL(10, 2) NOT NULL, -- Percentage or fixed amount value
min_purchase_amount DECIMAL(10, 2), -- Minimum purchase amount to use the coupon (optional)
max_discount_amount DECIMAL(10, 2), -- Maximum discount amount for percentage discounts (optional)
redemption_limit INTEGER, -- NULL means unlimited redemptions
current_redemptions INTEGER NOT NULL DEFAULT 0, -- Track how many times coupon has been used
start_date TIMESTAMP WITH TIME ZONE, -- When the coupon becomes valid (optional)
end_date TIMESTAMP WITH TIME ZONE, -- When the coupon expires (optional)
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- Whether the coupon is currently active
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create coupon_categories junction table
CREATE TABLE coupon_categories (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES product_categories(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, category_id)
);
-- Create coupon_tags junction table
CREATE TABLE coupon_tags (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, tag_id)
);
-- Create coupon_blacklist table for excluded products
CREATE TABLE coupon_blacklist (
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
PRIMARY KEY (coupon_id, product_id)
);
-- Create coupon_redemptions table to track usage
CREATE TABLE coupon_redemptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
discount_amount DECIMAL(10, 2) NOT NULL,
redeemed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add applied_coupon_id to orders table
ALTER TABLE orders ADD COLUMN coupon_id UUID REFERENCES coupons(id);
ALTER TABLE orders ADD COLUMN discount_amount DECIMAL(10, 2) DEFAULT 0.00;
-- Add indexes for better performance
CREATE INDEX idx_coupon_code ON coupons(code);
CREATE INDEX idx_coupon_is_active ON coupons(is_active);
CREATE INDEX idx_coupon_end_date ON coupons(end_date);
CREATE INDEX idx_coupon_redemptions_coupon_id ON coupon_redemptions(coupon_id);
CREATE INDEX idx_coupon_redemptions_user_id ON coupon_redemptions(user_id);
-- Create trigger to update the updated_at timestamp
CREATE TRIGGER update_coupons_modtime
BEFORE UPDATE ON coupons
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();

View file

@ -0,0 +1,84 @@
-- Create blog post categories
CREATE TABLE blog_categories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog posts table
CREATE TABLE blog_posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt TEXT,
author_id UUID NOT NULL REFERENCES users(id),
category_id UUID REFERENCES blog_categories(id),
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, published, archived
featured_image_path VARCHAR(255),
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog post tags junction table
CREATE TABLE blog_post_tags (
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- Create blog post images table
CREATE TABLE blog_post_images (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
image_path VARCHAR(255) NOT NULL,
caption TEXT,
display_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create blog comments table
CREATE TABLE blog_comments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
post_id UUID NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
parent_id UUID REFERENCES blog_comments(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create indexes for performance
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);
CREATE INDEX idx_blog_posts_category ON blog_posts(category_id);
CREATE INDEX idx_blog_posts_status ON blog_posts(status);
CREATE INDEX idx_blog_posts_published_at ON blog_posts(published_at);
CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_comments_post ON blog_comments(post_id);
CREATE INDEX idx_blog_comments_user ON blog_comments(user_id);
CREATE INDEX idx_blog_comments_parent ON blog_comments(parent_id);
CREATE INDEX idx_blog_post_images_post ON blog_post_images(post_id);
-- Create triggers to automatically update the updated_at column
CREATE TRIGGER update_blog_categories_modtime
BEFORE UPDATE ON blog_categories
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_blog_posts_modtime
BEFORE UPDATE ON blog_posts
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
CREATE TRIGGER update_blog_comments_modtime
BEFORE UPDATE ON blog_comments
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Insert default blog categories
INSERT INTO blog_categories (name, description) VALUES
('Announcements', 'Official announcements and company news'),
('Collections', 'Information about product collections and releases'),
('Tutorials', 'How-to guides and instructional content'),
('Behind the Scenes', 'Stories about our sourcing and process');

View file

@ -0,0 +1,80 @@
-- Create product reviews table
CREATE TABLE product_reviews (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
parent_id UUID REFERENCES product_reviews(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
content TEXT,
rating decimal CHECK (rating >= 0.0 AND rating <= 5.0),
is_approved BOOLEAN DEFAULT FALSE,
is_verified_purchase BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Add product average rating column to the products table
ALTER TABLE products ADD COLUMN IF NOT EXISTS average_rating DECIMAL(3, 2);
ALTER TABLE products ADD COLUMN IF NOT EXISTS review_count INTEGER DEFAULT 0;
-- Create indexes for performance
CREATE INDEX idx_product_reviews_product ON product_reviews(product_id);
CREATE INDEX idx_product_reviews_user ON product_reviews(user_id);
CREATE INDEX idx_product_reviews_parent ON product_reviews(parent_id);
CREATE INDEX idx_product_reviews_approved ON product_reviews(is_approved);
CREATE INDEX idx_product_reviews_rating ON product_reviews(rating);
-- Create trigger to automatically update the updated_at column
CREATE TRIGGER update_product_reviews_modtime
BEFORE UPDATE ON product_reviews
FOR EACH ROW EXECUTE FUNCTION update_modified_column();
-- Function to update product average rating and review count
CREATE OR REPLACE FUNCTION update_product_average_rating()
RETURNS TRIGGER AS $$
DECLARE
avg_rating DECIMAL(3, 2);
rev_count INTEGER;
BEGIN
-- Calculate average rating and count for approved top-level reviews
SELECT
AVG(rating)::DECIMAL(3, 2),
COUNT(*)
INTO
avg_rating,
rev_count
FROM product_reviews
WHERE product_id = NEW.product_id
AND parent_id IS NULL
AND is_approved = TRUE
AND rating IS NOT NULL;
-- Update the product with new average rating and count
UPDATE products
SET
average_rating = avg_rating,
review_count = rev_count
WHERE id = NEW.product_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create triggers to recalculate average rating when reviews are added/updated/deleted
CREATE TRIGGER update_product_rating_on_insert
AFTER INSERT ON product_reviews
FOR EACH ROW
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();
CREATE TRIGGER update_product_rating_on_update
AFTER UPDATE OF is_approved, rating ON product_reviews
FOR EACH ROW
WHEN (NEW.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();
CREATE TRIGGER update_product_rating_on_delete
AFTER DELETE ON product_reviews
FOR EACH ROW
WHEN (OLD.parent_id IS NULL) -- Only for top-level reviews
EXECUTE FUNCTION update_product_average_rating();

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,11 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import { Suspense, lazy } from 'react';
import { Suspense, lazy, useEffect } from 'react';
import { CircularProgress, Box } from '@mui/material';
import Notifications from './components/Notifications';
import ProtectedRoute from './components/ProtectedRoute';
import Notifications from '@components/Notifications';
import ProtectedRoute from '@components/ProtectedRoute';
import { StripeProvider } from './context/StripeContext';
import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
// Layouts
// Import layouts
import MainLayout from './layouts/MainLayout';
import AuthLayout from './layouts/AuthLayout';
import AdminLayout from './layouts/AdminLayout';
@ -31,6 +33,17 @@ const AdminSettingsPage = lazy(() => import('@pages/Admin/SettingsPage'));
const AdminReportsPage = lazy(() => import('@pages/Admin/ReportsPage'));
const UserOrdersPage = lazy(() => import('@pages/UserOrdersPage'));
const NotFoundPage = lazy(() => import('@pages/NotFoundPage'));
const CouponsPage = lazy(() => import('@pages/Admin/CouponsPage'));
const CouponEditPage = lazy(() => import('@pages/CouponEditPage'));
const CouponRedemptionsPage = lazy(() => import('@pages/CouponRedemptionsPage'));
const BlogPage = lazy(() => import('@pages/BlogPage'));
const BlogDetailPage = lazy(() => import('@pages/BlogDetailPage'));
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 = () => (
@ -40,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 />}>
@ -76,6 +138,9 @@ function App() {
<PaymentCancelPage />
</ProtectedRoute>
} />
{/* Blog routes */}
<Route path="blog" element={<BlogPage />} />
<Route path="blog/:slug" element={<BlogDetailPage />} />
</Route>
{/* Auth routes with AuthLayout */}
@ -102,6 +167,17 @@ function App() {
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="reports" element={<AdminReportsPage />} />
<Route path="coupons" element={<CouponsPage />} />
<Route path="coupons/new" element={<CouponEditPage />} />
<Route path="coupons/:id" element={<CouponEditPage />} />
<Route path="coupons/:id/redemptions" element={<CouponRedemptionsPage />} />
<Route path="blog" element={<AdminBlogPage />} />
<Route path="blog/new" element={<BlogEditPage />} />
<Route path="blog/:id" element={<BlogEditPage />} />
<Route path="blog-comments" element={<AdminBlogCommentsPage />} />
<Route path="product-reviews" element={<AdminProductReviewsPage />} />
<Route path="email-templates" element={<EmailTemplatesPage />} />
<Route path="branding" element={<BrandingPage />} /> {/* New Branding Route */}
</Route>
{/* Catch-all route for 404s */}

View file

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import {
TextField,
Button,
Box,
Typography,
CircularProgress,
Alert,
Paper,
Divider,
Chip
} from '@mui/material';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import couponService from '../services/couponService';
import { useAuth } from '../hooks/reduxHooks';
/**
* Component for inputting and applying coupon codes to the cart
*/
const CouponInput = () => {
const [couponCode, setCouponCode] = useState('');
const [error, setError] = useState(null);
const { user } = useAuth();
const queryClient = useQueryClient();
// Get current cart data from cache
const cartData = queryClient.getQueryData(['cart', user]);
// Apply coupon mutation
const applyCoupon = useMutation({
mutationFn: ({ userId, code }) => couponService.applyCoupon(userId, code),
onSuccess: (data) => {
// Update React Query cache directly
queryClient.setQueryData(['cart', user], data);
setError(null);
},
onError: (error) => {
setError(error.message || 'Failed to apply coupon');
},
});
// Remove coupon mutation
const removeCoupon = useMutation({
mutationFn: (userId) => couponService.removeCoupon(userId),
onSuccess: (data) => {
// Update React Query cache directly
queryClient.setQueryData(['cart', user], data);
setError(null);
},
onError: (error) => {
setError(error.message || 'Failed to remove coupon');
},
});
// Handle coupon code input change
const handleCouponChange = (e) => {
setCouponCode(e.target.value.toUpperCase());
setError(null);
};
// Handle applying coupon
const handleApplyCoupon = () => {
if (!couponCode) {
setError('Please enter a coupon code');
return;
}
applyCoupon.mutate({ userId: user, code: couponCode });
};
// Handle removing coupon
const handleRemoveCoupon = () => {
removeCoupon.mutate(user);
setCouponCode('');
};
// Check if a coupon is already applied
const hasCoupon = cartData?.couponCode;
return (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Discount Code
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{hasCoupon ? (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Chip
label={cartData.couponCode}
color="success"
sx={{ mr: 2 }}
/>
<Typography variant="body2" color="success.main">
Discount applied: -${cartData.couponDiscount?.toFixed(2)}
</Typography>
</Box>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleRemoveCoupon}
disabled={removeCoupon.isLoading}
>
{removeCoupon.isLoading ? <CircularProgress size={24} /> : 'Remove Coupon'}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex' }}>
<TextField
fullWidth
placeholder="Enter coupon code"
value={couponCode}
onChange={handleCouponChange}
disabled={applyCoupon.isLoading}
inputProps={{ style: { textTransform: 'uppercase' } }}
sx={{ mr: 2 }}
/>
<Button
variant="contained"
onClick={handleApplyCoupon}
disabled={!couponCode || applyCoupon.isLoading}
>
{applyCoupon.isLoading ? <CircularProgress size={24} /> : 'Apply'}
</Button>
</Box>
)}
</Paper>
);
};
export default CouponInput;

View file

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

View file

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

View file

@ -0,0 +1,27 @@
import React from 'react';
import { Box, Typography, Rating } from '@mui/material';
/**
* Component to display product rating in a compact format
*/
const ProductRatingDisplay = ({ rating, reviewCount, showEmpty = false }) => {
if (!rating && !reviewCount && !showEmpty) {
return null;
}
return (
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
<Rating
value={rating || 0}
readOnly
precision={0.5}
size="small"
/>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
{reviewCount ? `(${reviewCount})` : showEmpty ? '(0)' : ''}
</Typography>
</Box>
);
};
export default ProductRatingDisplay;

View file

@ -0,0 +1,328 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Rating,
Avatar,
Button,
TextField,
Paper,
Divider,
Card,
CardContent,
FormControlLabel,
Checkbox,
Alert,
CircularProgress
} from '@mui/material';
import { format } from 'date-fns';
import CommentIcon from '@mui/icons-material/Comment';
import VerifiedIcon from '@mui/icons-material/Verified';
import { useProductReviews, useCanReviewProduct, useAddProductReview } from '@hooks/productReviewHooks';
import { useAuth } from '@hooks/reduxHooks';
/**
* Component for displaying product reviews and allowing users to submit new reviews
*/
const ProductReviews = ({ productId }) => {
const { isAuthenticated } = useAuth();
const [replyTo, setReplyTo] = useState(null);
const [showReviewForm, setShowReviewForm] = useState(false);
const [formData, setFormData] = useState({
title: '',
content: '',
rating: 0
});
// Fetch reviews for this product
const { data: reviews, isLoading: reviewsLoading } = useProductReviews(productId);
// Check if user can submit a review
const { data: reviewPermission, isLoading: permissionLoading } = useCanReviewProduct(productId);
// Add review mutation
const addReview = useAddProductReview();
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Handle form changes
const handleFormChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle rating change
const handleRatingChange = (event, newValue) => {
setFormData(prev => ({ ...prev, rating: newValue }));
};
// Handle submit review
const handleSubmitReview = (e) => {
e.preventDefault();
if (!formData.title) {
return; // Title is required
}
if (!replyTo && (!formData.rating || formData.rating < 1)) {
return; // Rating is required for top-level reviews
}
const reviewData = {
title: formData.title,
content: formData.content,
rating: replyTo ? undefined : formData.rating,
parentId: replyTo ? replyTo.id : undefined
};
addReview.mutate({
productId,
reviewData
}, {
onSuccess: () => {
// Reset form
setFormData({
title: '',
content: '',
rating: 0
});
setReplyTo(null);
setShowReviewForm(false);
}
});
};
// Render a single review
const renderReview = (review, isReply = false) => (
<Card
key={review.id}
variant={isReply ? "outlined" : "elevation"}
sx={{
mb: 2,
ml: isReply ? 4 : 0,
bgcolor: isReply ? 'background.paper' : undefined
}}
>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Avatar sx={{ mr: 1 }}>
{review.first_name ? review.first_name[0] : '?'}
</Avatar>
<Box>
<Typography variant="subtitle1">
{review.first_name} {review.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(review.created_at)}
</Typography>
</Box>
</Box>
{review.is_verified_purchase && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<VerifiedIcon color="primary" fontSize="small" sx={{ mr: 0.5 }} />
<Typography variant="caption" color="primary">
Verified Purchase
</Typography>
</Box>
)}
</Box>
{!isReply && review.rating && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Rating value={review.rating} readOnly precision={0.5} />
<Typography variant="body2" sx={{ ml: 1 }}>
({review.rating}/5)
</Typography>
</Box>
)}
<Typography variant="h6" gutterBottom>{review.title}</Typography>
{review.content && (
<Typography variant="body2" paragraph>
{review.content}
</Typography>
)}
{isAuthenticated && (
<Button
size="small"
startIcon={<CommentIcon />}
onClick={() => {
setReplyTo(review);
setShowReviewForm(true);
// Scroll to form
document.getElementById('review-form')?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
>
Reply
</Button>
)}
</CardContent>
{/* Render replies */}
{review.replies && review.replies.length > 0 && (
<Box sx={{ px: 2, pb: 2 }}>
{review.replies.map(reply => renderReview(reply, true))}
</Box>
)}
</Card>
);
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h5" component="h2" gutterBottom>
Customer Reviews
{reviews && reviews.length > 0 && (
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
({reviews.length})
</Typography>
)}
</Typography>
<Divider sx={{ mb: 3 }} />
{/* Write a review button */}
{isAuthenticated && !permissionLoading && reviewPermission && (
<Box sx={{ mb: 3 }}>
{!showReviewForm ? (
<Button
variant="contained"
onClick={() => {
setReplyTo(null);
setShowReviewForm(true);
}}
disabled={!reviewPermission?.canReview}
>
Write a Review
</Button>
) : (
<Button
variant="outlined"
onClick={() => {
setReplyTo(null);
setShowReviewForm(false);
}}
>
Cancel
</Button>
)}
{!reviewPermission?.canReview && !reviewPermission?.isAdmin && (
<Alert severity="info" sx={{ mt: 2 }}>
{reviewPermission?.reason || 'You need to purchase this product before you can review it.'}
</Alert>
)}
</Box>
)}
{/* Review form */}
{showReviewForm && isAuthenticated && reviewPermission && (reviewPermission.canReview || replyTo) && (
<Paper id="review-form" sx={{ p: 3, mb: 4 }}>
<Typography variant="h6" gutterBottom>
{replyTo ? `Reply to ${replyTo.first_name}'s Review` : 'Write a Review'}
</Typography>
{replyTo && (
<Alert severity="info" sx={{ mb: 2 }}>
Replying to: "{replyTo.title}"
<Button
size="small"
onClick={() => setReplyTo(null)}
sx={{ ml: 2 }}
>
Cancel Reply
</Button>
</Alert>
)}
<form onSubmit={handleSubmitReview}>
<TextField
fullWidth
required
label="Review Title"
name="title"
value={formData.title}
onChange={handleFormChange}
margin="normal"
/>
{!replyTo && (
<Box sx={{ my: 2 }}>
<Typography component="legend">Rating *</Typography>
<Rating
name="rating"
value={formData.rating}
onChange={handleRatingChange}
precision={0.5}
size="large"
/>
{formData.rating === 0 && (
<Typography variant="caption" color="error">
Please select a rating
</Typography>
)}
</Box>
)}
<TextField
fullWidth
multiline
rows={4}
label="Review"
name="content"
value={formData.content}
onChange={handleFormChange}
margin="normal"
placeholder={replyTo ? "Write your reply..." : "Share your experience with this product..."}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="submit"
variant="contained"
disabled={
addReview.isLoading ||
!formData.title ||
(!replyTo && formData.rating < 1)
}
>
{addReview.isLoading ? (
<CircularProgress size={24} />
) : (
replyTo ? 'Post Reply' : 'Submit Review'
)}
</Button>
</Box>
</form>
</Paper>
)}
{/* Reviews list */}
{reviewsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : reviews && reviews.length > 0 ? (
<Box>
{reviews.map(review => renderReview(review))}
</Box>
) : (
<Alert severity="info">
This product doesn't have any reviews yet. Be the first to review it!
</Alert>
)}
</Box>
);
};
export default ProductReviews;

View file

@ -5,6 +5,9 @@ const initialState = {
items: [],
itemCount: 0,
total: 0,
subtotal: 0,
couponCode: null,
couponDiscount: 0,
loading: false,
error: null,
};
@ -27,12 +30,18 @@ export const cartSlice = createSlice({
state.items = action.payload.items;
state.itemCount = action.payload.itemCount;
state.total = action.payload.total;
state.subtotal = action.payload.subtotal || action.payload.total;
state.couponCode = action.payload.couponCode || null;
state.couponDiscount = action.payload.couponDiscount || 0;
},
clearCart: (state) => {
state.id = null;
state.items = [];
state.itemCount = 0;
state.total = 0;
state.subtotal = 0;
state.couponCode = null;
state.couponDiscount = 0;
},
clearCartError: (state) => {
state.error = null;
@ -52,6 +61,9 @@ export const {
export const selectCartItems = (state) => state.cart.items;
export const selectCartItemCount = (state) => state.cart.itemCount;
export const selectCartTotal = (state) => state.cart.total;
export const selectCartSubtotal = (state) => state.cart.subtotal;
export const selectCouponCode = (state) => state.cart.couponCode;
export const selectCouponDiscount = (state) => state.cart.couponDiscount;
export const selectCartLoading = (state) => state.cart.loading;
export const selectCartError = (state) => state.cart.error;

View file

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

View file

@ -0,0 +1,235 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import blogService from '@services/blogService';
import blogAdminService from '@services/blogAdminService';
import { useNotification } from './reduxHooks';
// Public blog hooks
export const useBlogPosts = (params) => {
return useQuery({
queryKey: ['blog-posts', params],
queryFn: () => blogService.getAllPosts(params),
});
};
export const useBlogPost = (slug) => {
return useQuery({
queryKey: ['blog-post', slug],
queryFn: () => blogService.getPostBySlug(slug),
enabled: !!slug,
});
};
export const useBlogCategories = () => {
return useQuery({
queryKey: ['blog-categories'],
queryFn: () => blogService.getAllCategories(),
});
};
export const useAddComment = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ postId, commentData }) => blogService.addComment(postId, commentData),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['blog-post', variables.postId] });
notification.showNotification(
data.message || 'Comment added successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to add comment',
'error'
);
},
});
};
// Admin blog hooks
export const useAdminBlogPosts = () => {
return useQuery({
queryKey: ['admin-blog-posts'],
queryFn: () => blogAdminService.getAllPosts(),
});
};
export const useAdminBlogPost = (id) => {
return useQuery({
queryKey: ['admin-blog-post', id],
queryFn: () => blogAdminService.getPostById(id),
enabled: !!id,
});
};
export const useCreateBlogPost = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (postData) => blogAdminService.createPost(postData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
notification.showNotification(
'Blog post created successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to create blog post',
'error'
);
},
});
};
export const useUpdateBlogPost = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ id, postData }) => blogAdminService.updatePost(id, postData),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
queryClient.invalidateQueries({ queryKey: ['admin-blog-post', variables.id] });
notification.showNotification(
'Blog post updated successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to update blog post',
'error'
);
},
});
};
export const useDeleteBlogPost = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (id) => blogAdminService.deletePost(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-posts'] });
notification.showNotification(
'Blog post deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete blog post',
'error'
);
},
});
};
export const useUploadBlogImage = () => {
const notification = useNotification();
return useMutation({
mutationFn: ({ postId, imageData }) => blogAdminService.uploadImage(postId, imageData),
onSuccess: (_, variables) => {
notification.showNotification(
'Image uploaded successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to upload image',
'error'
);
},
});
};
export const useDeleteBlogImage = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ postId, imageId }) => blogAdminService.deleteImage(postId, imageId),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['admin-blog-post', variables.postId] });
notification.showNotification(
'Image deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete image',
'error'
);
},
});
};
export const usePendingComments = () => {
return useQuery({
queryKey: ['pending-comments'],
queryFn: () => blogAdminService.getPendingComments(),
});
};
export const usePostComments = (postId) => {
return useQuery({
queryKey: ['post-comments', postId],
queryFn: () => blogAdminService.getPostComments(postId),
enabled: !!postId,
});
};
export const useApproveComment = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (commentId) => blogAdminService.approveComment(commentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pending-comments'] });
queryClient.invalidateQueries({ queryKey: ['post-comments'] });
notification.showNotification(
'Comment approved successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to approve comment',
'error'
);
},
});
};
export const useDeleteComment = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (commentId) => blogAdminService.deleteComment(commentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pending-comments'] });
queryClient.invalidateQueries({ queryKey: ['post-comments'] });
notification.showNotification(
'Comment deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete comment',
'error'
);
},
});
};

View file

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

View file

@ -0,0 +1,117 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import couponService from '../services/couponService';
import { useNotification } from './reduxHooks';
/**
* Hook for fetching all coupons (admin only)
*/
export const useAdminCoupons = () => {
return useQuery({
queryKey: ['admin-coupons'],
queryFn: couponService.getAllCoupons
});
};
/**
* Hook for fetching a single coupon by ID (admin only)
* @param {string} id - Coupon ID
*/
export const useAdminCoupon = (id) => {
return useQuery({
queryKey: ['admin-coupon', id],
queryFn: () => couponService.getCouponById(id),
enabled: !!id
});
};
/**
* Hook for creating a new coupon (admin only)
*/
export const useCreateCoupon = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (couponData) => couponService.createCoupon(couponData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
notification.showNotification('Coupon created successfully', 'success');
return data;
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to create coupon',
'error'
);
throw error;
},
});
};
/**
* Hook for updating a coupon (admin only)
*/
export const useUpdateCoupon = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ id, couponData }) => couponService.updateCoupon(id, couponData),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
queryClient.invalidateQueries({ queryKey: ['admin-coupon', data.coupon?.id] });
notification.showNotification('Coupon updated successfully', 'success');
return data;
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to update coupon',
'error'
);
throw error;
},
});
};
/**
* Hook for deleting a coupon (admin only)
*/
export const useDeleteCoupon = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (id) => couponService.deleteCoupon(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-coupons'] });
notification.showNotification('Coupon deleted successfully', 'success');
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete coupon',
'error'
);
throw error;
},
});
};
/**
* Hook for fetching coupon redemption history (admin only)
*/
export const useCouponRedemptions = (id) => {
return useQuery({
queryKey: ['admin-coupon-redemptions', id],
queryFn: () => couponService.getCouponRedemptions(id),
enabled: !!id
});
};
export default {
useAdminCoupons,
useAdminCoupon,
useCreateCoupon,
useUpdateCoupon,
useDeleteCoupon,
useCouponRedemptions
};

View file

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

View file

@ -0,0 +1,114 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import productReviewService, { productReviewAdminService } from '@services/productReviewService';
import { useNotification } from './reduxHooks';
// User-facing review hooks
export const useProductReviews = (productId) => {
return useQuery({
queryKey: ['product-reviews', productId],
queryFn: () => productReviewService.getProductReviews(productId),
enabled: !!productId
});
};
export const useCanReviewProduct = (productId) => {
return useQuery({
queryKey: ['can-review-product', productId],
queryFn: () => productReviewService.canReviewProduct(productId),
enabled: !!productId
});
};
export const useAddProductReview = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: ({ productId, reviewData }) =>
productReviewService.addProductReview(productId, reviewData),
onSuccess: (data, variables) => {
// Invalidate reviews for this product
queryClient.invalidateQueries({ queryKey: ['product-reviews', variables.productId] });
queryClient.invalidateQueries({ queryKey: ['can-review-product', variables.productId] });
notification.showNotification(
data.message || 'Review submitted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to submit review',
'error'
);
}
});
};
// Admin review hooks
export const usePendingReviews = () => {
return useQuery({
queryKey: ['pending-reviews'],
queryFn: productReviewAdminService.getPendingReviews
});
};
export const useAdminProductReviews = (productId) => {
return useQuery({
queryKey: ['admin-product-reviews', productId],
queryFn: () => productReviewAdminService.getProductReviews(productId),
enabled: !!productId
});
};
export const useApproveReview = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (reviewId) => productReviewAdminService.approveReview(reviewId),
onSuccess: () => {
// Invalidate both pending reviews and product reviews
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
queryClient.invalidateQueries({ queryKey: ['admin-product-reviews'] });
queryClient.invalidateQueries({ queryKey: ['product-reviews'] });
notification.showNotification(
'Review approved successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to approve review',
'error'
);
}
});
};
export const useDeleteReview = () => {
const queryClient = useQueryClient();
const notification = useNotification();
return useMutation({
mutationFn: (reviewId) => productReviewAdminService.deleteReview(reviewId),
onSuccess: () => {
// Invalidate both pending reviews and product reviews
queryClient.invalidateQueries({ queryKey: ['pending-reviews'] });
queryClient.invalidateQueries({ queryKey: ['admin-product-reviews'] });
queryClient.invalidateQueries({ queryKey: ['product-reviews'] });
notification.showNotification(
'Review deleted successfully',
'success'
);
},
onError: (error) => {
notification.showNotification(
error.message || 'Failed to delete review',
'error'
);
}
});
};

View file

@ -10,6 +10,7 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import PeopleIcon from '@mui/icons-material/People';
import SettingsIcon from '@mui/icons-material/Settings';
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
import BarChartIcon from '@mui/icons-material/BarChart';
import CategoryIcon from '@mui/icons-material/Category';
import HomeIcon from '@mui/icons-material/Home';
@ -18,8 +19,17 @@ import LogoutIcon from '@mui/icons-material/Logout';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
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 { 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;
@ -31,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) {
@ -64,9 +83,15 @@ const AdminLayout = () => {
{ text: 'Categories', icon: <ClassIcon />, path: '/admin/categories' },
{ text: 'Orders', icon: <ShoppingCartIcon />, path: '/admin/orders' },
{ text: 'Customers', icon: <PeopleIcon />, path: '/admin/customers' },
{ 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' },
];
const secondaryListItems = [
{ text: 'Visit Site', icon: <HomeIcon />, path: '/' },
@ -115,7 +140,7 @@ const AdminLayout = () => {
noWrap
sx={{ flexGrow: 1 }}
>
Admin Dashboard
{siteName} Admin Dashboard
</Typography>
</Toolbar>
</AppBar>

View file

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

View file

@ -15,9 +15,14 @@ import DashboardIcon from '@mui/icons-material/Dashboard';
import Brightness4Icon from '@mui/icons-material/Brightness4';
import Brightness7Icon from '@mui/icons-material/Brightness7';
import ReceiptIcon from '@mui/icons-material/Receipt';
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);
@ -29,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);
};
@ -41,6 +51,7 @@ const MainLayout = () => {
let mainMenu = [
{ text: 'Home', icon: <HomeIcon />, path: '/' },
{ text: 'Products', icon: <CategoryIcon />, path: '/products' },
{ text: 'Blog', icon: <BookIcon />, path: '/blog' },
{ text: 'Cart', icon: <ShoppingCartIcon />, path: '/cart', badge: itemCount > 0 ? itemCount : null },
];
if (isAuthenticated) {
@ -68,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>
@ -133,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 && (
@ -218,7 +266,7 @@ const MainLayout = () => {
</Container>
</Box>
<Footer />
<Footer brandingSettings={brandingSettings} />
</Box>
);
};

View file

@ -0,0 +1,333 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Card,
CardContent,
CardActions,
Button,
Divider,
Chip,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
InputAdornment,
IconButton,
Tooltip
} from '@mui/material';
import {
Check as ApproveIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Clear as ClearIcon,
Visibility as ViewIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { usePendingComments, useApproveComment, useDeleteComment } from '@hooks/blogHooks';
import { format } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
const AdminBlogCommentsPage = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [commentToDelete, setCommentToDelete] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Fetch pending comments
const { data: pendingComments, isLoading, error } = usePendingComments();
const approveComment = useApproveComment();
const deleteComment = useDeleteComment();
// Filter comments by search term
const filteredComments = pendingComments ? pendingComments.filter(comment =>
comment.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
comment.post_title?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
// Handle tab change
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// Handle search input change
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
};
// Handle refresh
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await queryClient.invalidateQueries(['pending-comments']);
// Optional timeout to ensure the refresh button animation is visible
setTimeout(() => {
setIsRefreshing(false);
}, 500);
} catch (error) {
console.error('Error refreshing comments:', error);
setIsRefreshing(false);
}
};
// Handle view post
const handleViewPost = (slug) => {
window.open(`/blog/${slug}`, '_blank');
};
// Handle approve comment
const handleApproveComment = async (id) => {
try {
await approveComment.mutateAsync(id);
} catch (error) {
console.error('Error approving comment:', error);
}
};
// Handle delete dialog open
const handleDeleteClick = (comment) => {
setCommentToDelete(comment);
setDeleteDialogOpen(true);
};
// Handle delete confirmation
const handleConfirmDelete = async () => {
if (commentToDelete) {
await deleteComment.mutateAsync(commentToDelete.id);
setDeleteDialogOpen(false);
setCommentToDelete(null);
}
};
// Handle delete cancellation
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setCommentToDelete(null);
};
// Format date
const formatDate = (dateString) => {
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
};
// Loading state
if (isLoading && !isRefreshing) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading comments: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1">
Blog Comments
</Typography>
<Tooltip title="Refresh comments">
<IconButton
onClick={handleRefresh}
color="primary"
disabled={isRefreshing}
>
{isRefreshing ? (
<CircularProgress size={24} />
) : (
<RefreshIcon />
)}
</IconButton>
</Tooltip>
</Box>
<Tabs
value={activeTab}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
sx={{ mb: 3 }}
>
<Tab label={`Pending Approval (${pendingComments?.length || 0})`} />
<Tab label="All Comments" />
</Tabs>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search comments by content, author, or post..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Comments List */}
{filteredComments.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
No pending comments found
</Typography>
<Typography variant="body2" color="text.secondary">
{searchTerm ? 'Try adjusting your search terms' : 'All comments have been reviewed'}
</Typography>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
onClick={handleRefresh}
sx={{ mt: 2 }}
disabled={isRefreshing}
>
Refresh
</Button>
</Paper>
) : (
<Box>
{filteredComments.map(comment => (
<Card key={comment.id} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1">
{comment.first_name} {comment.last_name} ({comment.email})
</Typography>
<Chip
label={formatDate(comment.created_at)}
variant="outlined"
size="small"
/>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" paragraph>
{comment.content}
</Typography>
<Box sx={{ bgcolor: 'background.paper', p: 1, borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
On post:
</Typography>
<Typography variant="body2">
{comment.post_title}
</Typography>
</Box>
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
startIcon={<ViewIcon />}
onClick={() => handleViewPost(comment.post_slug)}
>
View Post
</Button>
<Button
startIcon={<ApproveIcon />}
color="success"
onClick={() => handleApproveComment(comment.id)}
disabled={approveComment.isLoading}
>
Approve
</Button>
<Button
startIcon={<DeleteIcon />}
color="error"
onClick={() => handleDeleteClick(comment)}
disabled={deleteComment.isLoading}
>
Delete
</Button>
</CardActions>
</Card>
))}
</Box>
)}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete this comment? This action cannot be undone.
</DialogContentText>
{commentToDelete && (
<Paper variant="outlined" sx={{ p: 2, mt: 2, bgcolor: 'background.paper' }}>
<Typography variant="caption" color="text.secondary">
Comment by {commentToDelete.first_name} {commentToDelete.last_name}:
</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>
{commentToDelete.content}
</Typography>
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deleteComment.isLoading}
>
{deleteComment.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminBlogCommentsPage;

View file

@ -0,0 +1,476 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Grid,
Divider,
CircularProgress,
Alert,
Switch,
FormControlLabel,
FormHelperText,
Autocomplete,
IconButton,
Card,
CardContent,
CardMedia,
CardActions,
Tooltip,
Breadcrumbs,
Link
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Preview as PreviewIcon,
Delete as DeleteIcon
} from '@mui/icons-material';
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
import { useAdminBlogPost, useCreateBlogPost, useUpdateBlogPost, useBlogCategories } from '@hooks/blogHooks';
import { useAuth } from '@hooks/reduxHooks';
import ImageUploader from '@components/ImageUploader';
import imageUtils from '@utils/imageUtils';
const BlogEditPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const isNewPost = !id;
const { userData } = useAuth();
// Form state
const [formData, setFormData] = useState({
title: '',
content: '',
excerpt: '',
categoryId: '',
tags: [],
featuredImagePath: '',
status: 'draft',
publishNow: false
});
// Validation state
const [errors, setErrors] = useState({});
const [notificationOpen, setNotificationOpen] = useState(false);
const [notification, setNotification] = useState({ type: 'success', message: '' });
// Fetch blog post if editing
const {
data: post,
isLoading: postLoading,
error: postError
} = useAdminBlogPost(isNewPost ? null : id);
// Fetch categories
const { data: categories } = useBlogCategories();
// Mutations
const createPost = useCreateBlogPost();
const updatePost = useUpdateBlogPost();
// Handle form changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear validation error
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
// Handle switch changes
const handleSwitchChange = (e) => {
const { name, checked } = e.target;
setFormData(prev => ({ ...prev, [name]: checked }));
};
// Handle tags change
const handleTagsChange = (event, newValue) => {
setFormData(prev => ({ ...prev, tags: newValue }));
};
// Handle featured image change
const handleFeaturedImageChange = (images) => {
if (images && images.length > 0) {
setFormData(prev => ({ ...prev, featuredImagePath: images[0].path }));
} else {
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
}
};
// Clear featured image
const handleClearFeaturedImage = () => {
setFormData(prev => ({ ...prev, featuredImagePath: '' }));
};
// Validate form
const validateForm = () => {
const newErrors = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.content.trim()) {
newErrors.content = 'Content is required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
setNotification({
type: 'error',
message: 'Please fix the form errors before submitting'
});
setNotificationOpen(true);
return;
}
try {
const formattedData = {
...formData,
// Convert tag objects to string names if needed
tags: formData.tags.map(tag => typeof tag === 'string' ? tag : tag.name)
};
if (isNewPost) {
await createPost.mutateAsync(formattedData);
navigate('/admin/blog');
} else {
await updatePost.mutateAsync({ id, postData: formattedData });
setNotification({
type: 'success',
message: 'Blog post updated successfully'
});
setNotificationOpen(true);
}
} catch (error) {
setNotification({
type: 'error',
message: error.message || 'An error occurred while saving the post'
});
setNotificationOpen(true);
}
};
// Preview post
const handlePreview = () => {
// Store current form data in session storage
sessionStorage.setItem('blog-post-preview', JSON.stringify(formData));
window.open('/admin/blog/preview', '_blank');
};
// Load post data when available
useEffect(() => {
if (post && !isNewPost) {
setFormData({
title: post.title || '',
content: post.content || '',
excerpt: post.excerpt || '',
categoryId: post.category_id || '',
tags: post.tags || [],
featuredImagePath: post.featured_image_path || '',
status: post.status || 'draft',
publishNow: false
});
}
}, [post, isNewPost]);
// Loading state
if (postLoading && !isNewPost) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (postError && !isNewPost) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading blog post: {postError.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin/blog')}
sx={{ mb: 2 }}
>
Back to Blog Posts
</Button>
<Breadcrumbs sx={{ mb: 2 }}>
<Link component={RouterLink} to="/admin" color="inherit">
Admin
</Link>
<Link component={RouterLink} to="/admin/blog" color="inherit">
Blog
</Link>
<Typography color="text.primary">
{isNewPost ? 'Create Post' : 'Edit Post'}
</Typography>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>
{isNewPost ? 'Create New Blog Post' : `Edit Blog Post: ${post?.title || ''}`}
</Typography>
</Box>
{/* Form */}
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
{notificationOpen && (
<Alert
severity={notification.type}
sx={{ mb: 3 }}
onClose={() => setNotificationOpen(false)}
>
{notification.message}
</Alert>
)}
<Grid container spacing={3}>
{/* Title */}
<Grid item xs={12}>
<TextField
fullWidth
required
label="Post Title"
name="title"
value={formData.title}
onChange={handleChange}
error={!!errors.title}
helperText={errors.title}
/>
</Grid>
{/* Category */}
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="category-label">Category</InputLabel>
<Select
labelId="category-label"
name="categoryId"
value={formData.categoryId}
onChange={handleChange}
label="Category"
>
<MenuItem value="">
<em>None (Uncategorized)</em>
</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.id}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* Status */}
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="status-label">Status</InputLabel>
<Select
labelId="status-label"
name="status"
value={formData.status}
onChange={handleChange}
label="Status"
>
<MenuItem value="draft">Draft</MenuItem>
<MenuItem value="published">Published</MenuItem>
<MenuItem value="archived">Archived</MenuItem>
</Select>
<FormHelperText>
{formData.status === 'published' ?
'Published posts are visible to all users' :
formData.status === 'draft' ?
'Drafts are only visible to admins' :
'Archived posts are hidden but not deleted'
}
</FormHelperText>
</FormControl>
</Grid>
{/* Publish now option */}
{formData.status === 'published' && !post?.published_at && (
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={formData.publishNow}
onChange={handleSwitchChange}
name="publishNow"
color="primary"
/>
}
label="Publish immediately (sets published date to now)"
/>
</Grid>
)}
{/* Featured Image */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Featured Image
</Typography>
{formData.featuredImagePath ? (
<Card sx={{ maxWidth: 400, mb: 2 }}>
<CardMedia
component="img"
height="200"
image={imageUtils.getImageUrl(formData.featuredImagePath)}
alt="Featured image"
sx={{ objectFit: 'cover' }}
/>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
startIcon={<DeleteIcon />}
color="error"
onClick={handleClearFeaturedImage}
>
Remove
</Button>
</CardActions>
</Card>
) : (
<Box sx={{ mb: 2 }}>
<ImageUploader
multiple={false}
onChange={handleFeaturedImageChange}
admin={true}
/>
</Box>
)}
</Grid>
{/* Content */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Post Content
</Typography>
<TextField
fullWidth
required
multiline
minRows={10}
maxRows={20}
name="content"
value={formData.content}
onChange={handleChange}
placeholder="Write your blog post content here..."
error={!!errors.content}
helperText={errors.content}
variant="outlined"
/>
<FormHelperText>
Pro tip: You can use HTML markup for formatting
</FormHelperText>
</Grid>
{/* Excerpt */}
<Grid item xs={12}>
<TextField
fullWidth
multiline
rows={3}
label="Excerpt"
name="excerpt"
value={formData.excerpt}
onChange={handleChange}
placeholder="Write a short summary of your post (optional)"
variant="outlined"
helperText="If left empty, an excerpt will be automatically generated from your content"
/>
</Grid>
{/* Tags */}
<Grid item xs={12}>
<Autocomplete
multiple
freeSolo
options={[]}
value={formData.tags}
onChange={handleTagsChange}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
label={typeof option === 'string' ? option : option.name}
{...getTagProps({ index })}
key={index}
/>
))
}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
placeholder="Add tags and press Enter"
helperText="Tags help users find related content"
/>
)}
/>
</Grid>
{/* Actions */}
<Grid item xs={12} sx={{ mt: 2, display: 'flex', justifyContent: 'space-between' }}>
<Button
variant="outlined"
startIcon={<PreviewIcon />}
onClick={handlePreview}
disabled={!formData.title || !formData.content}
>
Preview
</Button>
<Box>
<Button
variant="outlined"
sx={{ mr: 2 }}
onClick={() => navigate('/admin/blog')}
>
Cancel
</Button>
<Button
type="submit"
variant="contained"
color="primary"
startIcon={createPost.isLoading || updatePost.isLoading ?
<CircularProgress size={20} /> : <SaveIcon />}
disabled={createPost.isLoading || updatePost.isLoading}
>
{createPost.isLoading || updatePost.isLoading ?
'Saving...' : (isNewPost ? 'Create' : 'Update')} Post
</Button>
</Box>
</Grid>
</Grid>
</Paper>
</Box>
);
};
export default BlogEditPage;

View file

@ -0,0 +1,342 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Button,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
TextField,
InputAdornment,
Chip,
CircularProgress,
Alert,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip
} from '@mui/material';
import {
Edit as EditIcon,
Delete as DeleteIcon,
Add as AddIcon,
Search as SearchIcon,
Clear as ClearIcon,
Visibility as ViewIcon
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAdminBlogPosts, useDeleteBlogPost } from '@hooks/blogHooks';
import { format } from 'date-fns';
import imageUtils from '@utils/imageUtils';
const AdminBlogPage = () => {
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [postToDelete, setPostToDelete] = useState(null);
// Fetch blog posts
const { data: posts, isLoading, error } = useAdminBlogPosts();
const deletePost = useDeleteBlogPost();
// Filter posts based on search term
const filteredPosts = posts ? posts.filter(post =>
post.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.excerpt?.toLowerCase().includes(searchTerm.toLowerCase()) ||
post.category_name?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
// Handle search input change
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
};
// Handle edit post
const handleEditPost = (id) => {
navigate(`/admin/blog/${id}`);
};
// Handle view post
const handleViewPost = (slug) => {
window.open(`/blog/${slug}`, '_blank');
};
// Handle delete dialog open
const handleDeleteClick = (post) => {
setPostToDelete(post);
setDeleteDialogOpen(true);
};
// Handle delete confirmation
const handleConfirmDelete = async () => {
if (postToDelete) {
await deletePost.mutateAsync(postToDelete.id);
setDeleteDialogOpen(false);
setPostToDelete(null);
}
};
// Handle delete cancellation
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setPostToDelete(null);
};
// Format date
const formatDate = (dateString) => {
if (!dateString) return 'Not published';
return format(new Date(dateString), 'MMM d, yyyy');
};
// Get status chip color
const getStatusColor = (status) => {
switch (status) {
case 'published':
return 'success';
case 'draft':
return 'warning';
case 'archived':
return 'default';
default:
return 'default';
}
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading blog posts: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" component="h1">
Blog Management
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
component={RouterLink}
to="/admin/blog/new"
>
Create Post
</Button>
</Box>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search posts by title, excerpt, or category..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Blog Posts Table */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50px">Image</TableCell>
<TableCell>Title</TableCell>
<TableCell>Category</TableCell>
<TableCell>Status</TableCell>
<TableCell>Published</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPosts.length > 0 ? (
filteredPosts.map((post) => (
<TableRow key={post.id}>
<TableCell>
{post.featured_image_path ? (
<Box
component="img"
src={imageUtils.getImageUrl(post.featured_image_path)}
alt={post.title}
sx={{
width: 50,
height: 50,
objectFit: 'cover',
borderRadius: 1
}}
/>
) : (
<Box
sx={{
width: 50,
height: 50,
bgcolor: 'grey.200',
borderRadius: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography variant="caption" color="text.secondary">
No img
</Typography>
</Box>
)}
</TableCell>
<TableCell>
<Typography variant="subtitle2" noWrap sx={{ maxWidth: 250 }}>
{post.title}
</Typography>
{post.excerpt && (
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
display: 'block',
maxWidth: 250
}}
>
{post.excerpt}
</Typography>
)}
</TableCell>
<TableCell>
{post.category_name || 'Uncategorized'}
</TableCell>
<TableCell>
<Chip
label={post.status}
color={getStatusColor(post.status)}
size="small"
/>
</TableCell>
<TableCell>
{formatDate(post.published_at)}
</TableCell>
<TableCell>
{formatDate(post.created_at)}
</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton
color="info"
onClick={() => handleViewPost(post.slug)}
size="small"
>
<ViewIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
color="primary"
onClick={() => handleEditPost(post.id)}
size="small"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
color="error"
onClick={() => handleDeleteClick(post)}
size="small"
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body1" py={3}>
{searchTerm ? 'No posts match your search.' : 'No blog posts found.'}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete the post <strong>{postToDelete?.title}</strong>?
This action cannot be undone and all comments will be permanently removed.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deletePost.isLoading}
>
{deletePost.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminBlogPage;

View file

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

View file

@ -0,0 +1,380 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Button,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
TextField,
InputAdornment,
Chip,
CircularProgress,
Alert,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Tooltip
} from '@mui/material';
import {
Edit as EditIcon,
Delete as DeleteIcon,
Add as AddIcon,
Search as SearchIcon,
Clear as ClearIcon,
History as HistoryIcon
} from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useAdminCoupons, useDeleteCoupon } from '@hooks/couponAdminHooks';
import { format, isPast, isFuture } from 'date-fns';
const AdminCouponsPage = () => {
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [search, setSearch] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [couponToDelete, setCouponToDelete] = useState(null);
// Fetch coupons
const { data: coupons, isLoading, error } = useAdminCoupons();
// Delete mutation
const deleteCoupon = useDeleteCoupon();
// Filter coupons by search
const filteredCoupons = coupons ? coupons.filter(coupon => {
const searchTerm = search.toLowerCase();
return (
coupon.code.toLowerCase().includes(searchTerm) ||
(coupon.description && coupon.description.toLowerCase().includes(searchTerm))
);
}) : [];
// Paginate coupons
const paginatedCoupons = filteredCoupons.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
);
// Handle search change
const handleSearchChange = (event) => {
setSearch(event.target.value);
setPage(0);
};
// Clear search
const handleClearSearch = () => {
setSearch('');
setPage(0);
};
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
// Handle edit action
const handleEditCoupon = (id) => {
navigate(`/admin/coupons/${id}`);
};
// Handle delete click
const handleDeleteClick = (coupon) => {
setCouponToDelete(coupon);
setDeleteDialogOpen(true);
};
// Confirm delete
const handleConfirmDelete = () => {
if (couponToDelete) {
deleteCoupon.mutate(couponToDelete.id, {
onSuccess: () => {
setDeleteDialogOpen(false);
setCouponToDelete(null);
}
});
}
};
// Cancel delete
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setCouponToDelete(null);
};
// Navigate to view redemptions
const handleViewRedemptions = (id) => {
navigate(`/admin/coupons/${id}/redemptions`);
};
// Format date
const formatDate = (dateString) => {
if (!dateString) return 'No Date Set';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch (error) {
return dateString;
}
};
// Get coupon status
const getCouponStatus = (coupon) => {
if (!coupon.is_active) {
return { label: 'Inactive', color: 'default' };
}
if (coupon.start_date && isFuture(new Date(coupon.start_date))) {
return { label: 'Scheduled', color: 'info' };
}
if (coupon.end_date && isPast(new Date(coupon.end_date))) {
return { label: 'Expired', color: 'error' };
}
if (coupon.redemption_limit !== null && coupon.current_redemptions >= coupon.redemption_limit) {
return { label: 'Fully Redeemed', color: 'warning' };
}
return { label: 'Active', color: 'success' };
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading coupons: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h4" component="h1">
Coupons & Discounts
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<AddIcon />}
onClick={() => navigate('/admin/coupons/new')}
>
Add Coupon
</Button>
</Box>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search coupons by code or description..."
value={search}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Coupons Table */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Type</TableCell>
<TableCell>Value</TableCell>
<TableCell>Status</TableCell>
<TableCell>Redemptions</TableCell>
<TableCell>Valid Period</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedCoupons.length > 0 ? (
paginatedCoupons.map((coupon) => {
const status = getCouponStatus(coupon);
return (
<TableRow key={coupon.id}>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>
{coupon.code}
</Typography>
<Typography variant="caption" color="text.secondary">
{coupon.description || 'No description'}
</Typography>
</TableCell>
<TableCell>
{coupon.discount_type === 'percentage' ? 'Percentage' : 'Fixed Amount'}
</TableCell>
<TableCell>
{coupon.discount_type === 'percentage'
? `${coupon.discount_value}%`
: `$${parseFloat(coupon.discount_value).toFixed(2)}`}
{coupon.max_discount_amount && (
<Typography variant="caption" display="block" color="text.secondary">
Max: ${parseFloat(coupon.max_discount_amount).toFixed(2)}
</Typography>
)}
</TableCell>
<TableCell>
<Chip
label={status.label}
color={status.color}
size="small"
/>
</TableCell>
<TableCell>
{coupon.redemption_limit ? (
<Typography variant="body2">
{coupon.current_redemptions} / {coupon.redemption_limit}
</Typography>
) : (
<Typography variant="body2">
{coupon.current_redemptions} / Unlimited
</Typography>
)}
</TableCell>
<TableCell>
{coupon.start_date && (
<Typography variant="caption" display="block">
From: {formatDate(coupon.start_date)}
</Typography>
)}
{coupon.end_date ? (
<Typography variant="caption" display="block">
To: {formatDate(coupon.end_date)}
</Typography>
) : (
<Typography variant="caption" display="block">
No End Date
</Typography>
)}
</TableCell>
<TableCell align="right">
<Tooltip title="View Redemption History">
<IconButton
onClick={() => handleViewRedemptions(coupon.id)}
color="primary"
>
<HistoryIcon />
</IconButton>
</Tooltip>
<Tooltip title="Edit Coupon">
<IconButton
onClick={() => handleEditCoupon(coupon.id)}
color="primary"
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete Coupon">
<IconButton
onClick={() => handleDeleteClick(coupon)}
color="error"
>
<DeleteIcon />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body1" py={2}>
{search ? 'No coupons match your search.' : 'No coupons found.'}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50]}
component="div"
count={filteredCoupons.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</Paper>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete the coupon <strong>{couponToDelete?.code}</strong>?
This action cannot be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deleteCoupon.isLoading}
>
{deleteCoupon.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminCouponsPage;

View file

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

View file

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

View file

@ -0,0 +1,366 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Tabs,
Tab,
Card,
CardContent,
CardActions,
Button,
Divider,
Chip,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
TextField,
InputAdornment,
IconButton,
Tooltip,
Rating
} from '@mui/material';
import {
Check as ApproveIcon,
Delete as DeleteIcon,
Search as SearchIcon,
Clear as ClearIcon,
Visibility as ViewIcon,
Refresh as RefreshIcon
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { usePendingReviews, useApproveReview, useDeleteReview } from '@hooks/productReviewHooks';
import { format } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
const AdminProductReviewsPage = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [reviewToDelete, setReviewToDelete] = useState(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Fetch pending reviews
const { data: pendingReviews, isLoading, error } = usePendingReviews();
const approveReview = useApproveReview();
const deleteReview = useDeleteReview();
// Filter reviews by search term
const filteredReviews = pendingReviews ? pendingReviews.filter(review =>
review.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.content?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.first_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.last_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.product_name?.toLowerCase().includes(searchTerm.toLowerCase())
) : [];
// Handle tab change
const handleTabChange = (event, newValue) => {
setActiveTab(newValue);
};
// Handle search input change
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// Clear search
const handleClearSearch = () => {
setSearchTerm('');
};
// Handle refresh
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await queryClient.invalidateQueries(['pending-reviews']);
// Optional timeout to ensure the refresh button animation is visible
setTimeout(() => {
setIsRefreshing(false);
}, 500);
} catch (error) {
console.error('Error refreshing reviews:', error);
setIsRefreshing(false);
}
};
// Handle view product
const handleViewProduct = (productId) => {
window.open(`/products/${productId}`, '_blank');
};
// Handle approve review
const handleApproveReview = async (id) => {
try {
await approveReview.mutateAsync(id);
} catch (error) {
console.error('Error approving review:', error);
}
};
// Handle delete dialog open
const handleDeleteClick = (review) => {
setReviewToDelete(review);
setDeleteDialogOpen(true);
};
// Handle delete confirmation
const handleConfirmDelete = async () => {
if (reviewToDelete) {
await deleteReview.mutateAsync(reviewToDelete.id);
setDeleteDialogOpen(false);
setReviewToDelete(null);
}
};
// Handle delete cancellation
const handleCancelDelete = () => {
setDeleteDialogOpen(false);
setReviewToDelete(null);
};
// Format date
const formatDate = (dateString) => {
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
};
// Loading state
if (isLoading && !isRefreshing) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading reviews: {error.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" component="h1">
Product Reviews
</Typography>
<Tooltip title="Refresh reviews">
<IconButton
onClick={handleRefresh}
color="primary"
disabled={isRefreshing}
>
{isRefreshing ? (
<CircularProgress size={24} />
) : (
<RefreshIcon />
)}
</IconButton>
</Tooltip>
</Box>
<Tabs
value={activeTab}
onChange={handleTabChange}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
sx={{ mb: 3 }}
>
<Tab label={`Pending Approval (${pendingReviews?.length || 0})`} />
<Tab label="All Reviews" />
</Tabs>
{/* Search Box */}
<Paper sx={{ p: 2, mb: 3 }}>
<TextField
fullWidth
placeholder="Search reviews by content, author, or product..."
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: searchTerm && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Paper>
{/* Reviews List */}
{filteredReviews.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
No pending reviews found
</Typography>
<Typography variant="body2" color="text.secondary">
{searchTerm ? 'Try adjusting your search terms' : 'All reviews have been approved'}
</Typography>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
onClick={handleRefresh}
sx={{ mt: 2 }}
disabled={isRefreshing}
>
Refresh
</Button>
</Paper>
) : (
<Box>
{filteredReviews.map(review => (
<Card key={review.id} sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography variant="subtitle1">
{review.first_name} {review.last_name} ({review.email})
</Typography>
<Chip
label={formatDate(review.created_at)}
variant="outlined"
size="small"
/>
{review.is_verified_purchase && (
<Chip
label="Verified Purchase"
color="success"
size="small"
sx={{ ml: 1 }}
/>
)}
</Box>
<Box>
<Chip
label={`Product: ${review.product_name}`}
color="primary"
variant="outlined"
/>
</Box>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="h6">{review.title}</Typography>
{review.rating && (
<Box sx={{ display: 'flex', alignItems: 'center', my: 1 }}>
<Rating value={review.rating} readOnly precision={0.5} />
<Typography variant="body2" sx={{ ml: 1 }}>
({review.rating}/5)
</Typography>
</Box>
)}
<Typography variant="body2" paragraph>
{review.content || <em>No content provided</em>}
</Typography>
{review.parent_id && (
<Alert severity="info" sx={{ mt: 1 }}>
This is a reply to another review
</Alert>
)}
</CardContent>
<CardActions sx={{ justifyContent: 'flex-end' }}>
<Button
startIcon={<ViewIcon />}
onClick={() => handleViewProduct(review.product_id)}
>
View Product
</Button>
<Button
startIcon={<ApproveIcon />}
color="success"
onClick={() => handleApproveReview(review.id)}
disabled={approveReview.isLoading}
>
Approve
</Button>
<Button
startIcon={<DeleteIcon />}
color="error"
onClick={() => handleDeleteClick(review)}
disabled={deleteReview.isLoading}
>
Delete
</Button>
</CardActions>
</Card>
))}
</Box>
)}
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
Confirm Delete
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
Are you sure you want to delete this review? This action cannot be undone.
</DialogContentText>
{reviewToDelete && (
<Paper variant="outlined" sx={{ p: 2, mt: 2, bgcolor: 'background.paper' }}>
<Typography variant="subtitle1">
Review by {reviewToDelete.first_name} {reviewToDelete.last_name}:
</Typography>
<Typography variant="h6" gutterBottom>
{reviewToDelete.title}
</Typography>
{reviewToDelete.rating && (
<Rating value={reviewToDelete.rating} readOnly size="small" />
)}
<Typography variant="body2" sx={{ mt: 1 }}>
{reviewToDelete.content || <em>No content provided</em>}
</Typography>
</Paper>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCancelDelete}>Cancel</Button>
<Button
onClick={handleConfirmDelete}
color="error"
variant="contained"
autoFocus
disabled={deleteReview.isLoading}
>
{deleteReview.isLoading ? (
<CircularProgress size={24} sx={{ mr: 1 }} />
) : null}
Delete
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default AdminProductReviewsPage;

View file

@ -0,0 +1,361 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Divider,
Chip,
Button,
Avatar,
TextField,
Grid,
Card,
CardContent,
Breadcrumbs,
Link,
CircularProgress,
Alert,
Container
} from '@mui/material';
import { useParams, Link as RouterLink, useNavigate } from 'react-router-dom';
import { useBlogPost, useAddComment } from '@hooks/blogHooks';
import { useAuth } from '@hooks/reduxHooks';
import { format } from 'date-fns';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import CommentIcon from '@mui/icons-material/Comment';
import SendIcon from '@mui/icons-material/Send';
import imageUtils from '@utils/imageUtils';
const BlogDetailPage = () => {
const { slug } = useParams();
const navigate = useNavigate();
const { isAuthenticated, user, userData } = useAuth();
const [comment, setComment] = useState('');
const [replyTo, setReplyTo] = useState(null);
// Fetch blog post
const { data: post, isLoading, error } = useBlogPost(slug);
const addComment = useAddComment();
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Handle comment submission
const handleSubmitComment = async (e) => {
e.preventDefault();
if (!comment.trim()) return;
try {
await addComment.mutateAsync({
postId: post.id,
commentData: {
userId: userData.id,
content: comment,
parentId: replyTo ? replyTo.id : null
}
});
// Reset comment form
setComment('');
setReplyTo(null);
} catch (error) {
console.error('Error submitting comment:', error);
}
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 4 }}>
Error loading blog post: {error.message}
</Alert>
);
}
// If post isn't found
if (!post) {
return (
<Alert severity="warning" sx={{ my: 4 }}>
Blog post not found. The post may have been removed or the URL is incorrect.
</Alert>
);
}
// Comments component
const renderComment = (comment, level = 0) => (
<Box key={comment.id} sx={{ ml: level * 3, mb: 2 }}>
<Card variant="outlined" sx={{ bgcolor: 'background.paper' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar sx={{ mr: 1 }}>
{comment.first_name ? comment.first_name[0] : '?'}
</Avatar>
<Box>
<Typography variant="subtitle2">
{comment.first_name} {comment.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(comment.created_at)}
</Typography>
</Box>
</Box>
<Typography variant="body2" paragraph>
{comment.content}
</Typography>
{isAuthenticated && (
<Button
size="small"
startIcon={<CommentIcon />}
onClick={() => setReplyTo(comment)}
>
Reply
</Button>
)}
</CardContent>
</Card>
{/* Render replies */}
{comment.replies && comment.replies.map(reply => renderComment(reply, level + 1))}
</Box>
);
return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
{/* Breadcrumbs */}
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb"
sx={{ mb: 3 }}
>
<Link component={RouterLink} to="/" color="inherit">
Home
</Link>
<Link component={RouterLink} to="/blog" color="inherit">
Blog
</Link>
<Typography color="text.primary">
{post.title}
</Typography>
</Breadcrumbs>
{/* Post header */}
<Paper sx={{ p: 3, mb: 4 }}>
{/* Category */}
{post.category_name && (
<Chip
label={post.category_name}
color="primary"
size="small"
sx={{ mb: 2 }}
/>
)}
{/* Title */}
<Typography variant="h4" component="h1" gutterBottom>
{post.title}
</Typography>
{/* Author and date */}
<Typography variant="subtitle1" color="text.secondary" gutterBottom>
By {post.author_first_name} {post.author_last_name} {formatDate(post.published_at)}
</Typography>
{/* Tags */}
<Box sx={{ mt: 2 }}>
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
component={RouterLink}
to={`/blog?tag=${tag}`}
clickable
sx={{ mr: 1, mb: 1 }}
/>
))}
</Box>
</Paper>
{/* Featured image */}
{post.featured_image_path && (
<Box sx={{ mb: 4, textAlign: 'center' }}>
<img
src={imageUtils.getImageUrl(post.featured_image_path)}
alt={post.title}
style={{
maxWidth: '100%',
maxHeight: '500px',
objectFit: 'contain',
borderRadius: '4px'
}}
/>
</Box>
)}
{/* Post content */}
<Paper sx={{ p: { xs: 2, md: 4 }, mb: 4 }}>
<Box
className="blog-content"
sx={{
'& p': { mb: 2 },
'& h2': { mt: 3, mb: 2 },
'& h3': { mt: 2.5, mb: 1.5 },
'& ul, & ol': { mb: 2, pl: 4 },
'& li': { mb: 0.5 },
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: '4px',
my: 2
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'primary.main',
pl: 2,
py: 1,
my: 2,
fontStyle: 'italic',
bgcolor: 'background.paper'
}
}}
>
{/* Post content - rendered as HTML */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</Box>
{/* Post images */}
{post.images && post.images.length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom>
Gallery
</Typography>
<Grid container spacing={2}>
{post.images.map((image) => (
<Grid item xs={12} sm={6} md={4} key={image.id}>
<Box
sx={{
position: 'relative',
pb: '75%', // 4:3 aspect ratio
height: 0,
overflow: 'hidden',
borderRadius: 1
}}
>
<img
src={imageUtils.getImageUrl(image.path)}
alt={image.caption || 'Blog image'}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
</Box>
{image.caption && (
<Typography variant="caption" color="text.secondary" display="block" align="center" sx={{ mt: 1 }}>
{image.caption}
</Typography>
)}
</Grid>
))}
</Grid>
</Box>
)}
</Paper>
{/* Comments section */}
<Paper sx={{ p: 3 }}>
<Typography variant="h5" component="h2" gutterBottom>
Comments ({post.comments ? post.comments.length : 0})
</Typography>
<Divider sx={{ mb: 3 }} />
{/* Comment form */}
{isAuthenticated ? (
<Box component="form" onSubmit={handleSubmitComment} sx={{ mb: 4 }}>
<Typography variant="subtitle1" gutterBottom>
{replyTo
? `Reply to ${replyTo.first_name}'s comment`
: 'Leave a comment'}
</Typography>
{replyTo && (
<Box sx={{ mb: 2 }}>
<Chip
label={`Cancel reply to ${replyTo.first_name}`}
onDelete={() => setReplyTo(null)}
variant="outlined"
/>
</Box>
)}
<TextField
fullWidth
multiline
rows={4}
placeholder="Write your comment here..."
value={comment}
onChange={(e) => setComment(e.target.value)}
variant="outlined"
sx={{ mb: 2 }}
/>
<Button
type="submit"
variant="contained"
color="primary"
endIcon={<SendIcon />}
disabled={!comment.trim() || addComment.isLoading}
>
{addComment.isLoading ? 'Submitting...' : 'Submit Comment'}
</Button>
{addComment.isSuccess && (
<Alert severity="success" sx={{ mt: 2 }}>
Your comment has been submitted and is awaiting approval.
</Alert>
)}
</Box>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
Please <Link component={RouterLink} to="/auth/login" state={{ from: `/blog/${slug}` }}>log in</Link> to leave a comment.
</Alert>
)}
{/* Comments list */}
{post.comments && post.comments.length > 0 ? (
<Box>
{post.comments.map(comment => renderComment(comment))}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
No comments yet. Be the first to comment!
</Typography>
)}
</Paper>
</Box>
</Container>
);
};
export default BlogDetailPage;

View file

@ -0,0 +1,312 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Card,
CardMedia,
CardContent,
CardActions,
Button,
Chip,
TextField,
InputAdornment,
IconButton,
Divider,
Pagination,
MenuItem,
Select,
FormControl,
InputLabel,
CircularProgress,
Alert,
Container
} from '@mui/material';
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
import { format } from 'date-fns';
import imageUtils from '@utils/imageUtils';
const BlogPage = () => {
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
// State for filters and search
const [filters, setFilters] = useState({
category: searchParams.get('category') || '',
tag: searchParams.get('tag') || '',
search: searchParams.get('search') || '',
page: parseInt(searchParams.get('page') || '1'),
});
// Fetch blog posts
const { data, isLoading, error } = useBlogPosts(filters);
const { data: categories } = useBlogCategories();
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.tag) params.set('tag', filters.tag);
if (filters.search) params.set('search', filters.search);
if (filters.page > 1) params.set('page', filters.page.toString());
navigate(`/blog${params.toString() ? `?${params.toString()}` : ''}`, { replace: true });
}, [filters, navigate]);
// Handle search input
const handleSearchChange = (e) => {
setFilters({ ...filters, search: e.target.value, page: 1 });
};
// Clear search
const handleClearSearch = () => {
setFilters({ ...filters, search: '', page: 1 });
};
// Handle category change
const handleCategoryChange = (e) => {
setFilters({ ...filters, category: e.target.value, page: 1 });
};
// Handle tag click
const handleTagClick = (tag) => {
setFilters({ ...filters, tag, page: 1 });
};
// Clear tag filter
const handleClearTag = () => {
setFilters({ ...filters, tag: '', page: 1 });
};
// Handle pagination
const handlePageChange = (event, value) => {
setFilters({ ...filters, page: value });
// Scroll to top when page changes
window.scrollTo(0, 0);
};
// Format date for display
const formatPublishedDate = (dateString) => {
if (!dateString) return '';
return format(new Date(dateString), 'MMMM d, yyyy');
};
// Loading state
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (error) {
return (
<Alert severity="error" sx={{ my: 4 }}>
Error loading blog posts: {error.message}
</Alert>
);
}
// Empty state
const posts = data?.posts || [];
const pagination = data?.pagination || { page: 1, pages: 1, total: 0 };
return (
<Container maxWidth="lg">
<Box sx={{ py: 4 }}>
<Typography variant="h4" component="h1" gutterBottom>
Our Blog
</Typography>
<Typography variant="subtitle1" color="text.secondary" paragraph>
Discover insights about our natural collections, sourcing adventures, and unique specimens
</Typography>
{/* Filters and Search */}
<Grid container spacing={2} sx={{ mb: 4, mt: 2 }}>
{/* Category filter */}
<Grid item xs={12} md={4}>
<FormControl fullWidth variant="outlined">
<InputLabel id="category-filter-label">Filter by Category</InputLabel>
<Select
labelId="category-filter-label"
value={filters.category}
onChange={handleCategoryChange}
label="Filter by Category"
>
<MenuItem value="">All Categories</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.name}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
{/* Tag filter */}
<Grid item xs={12} md={8}>
{filters.tag && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2" sx={{ mr: 1 }}>
Filtered by tag:
</Typography>
<Chip
label={filters.tag}
onDelete={handleClearTag}
color="primary"
size="small"
/>
</Box>
)}
</Grid>
{/* Search */}
<Grid item xs={12}>
<TextField
fullWidth
placeholder="Search blog posts..."
value={filters.search}
onChange={handleSearchChange}
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: filters.search && (
<InputAdornment position="end">
<IconButton size="small" onClick={handleClearSearch}>
<ClearIcon />
</IconButton>
</InputAdornment>
)
}}
/>
</Grid>
</Grid>
{/* No results message */}
{posts.length === 0 && (
<Box sx={{ textAlign: 'center', my: 8 }}>
<Typography variant="h6" gutterBottom>
No blog posts found
</Typography>
<Typography variant="body1" color="text.secondary">
{filters.search || filters.category || filters.tag
? 'Try adjusting your filters or search terms'
: 'Check back soon for new content'}
</Typography>
</Box>
)}
{/* Blog post grid */}
<Grid container spacing={4}>
{posts.map((post) => (
<Grid item xs={12} sm={6} md={4} key={post.id}>
<Card sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: 6
}
}}>
{/* Featured image */}
<CardMedia
component="img"
height="200"
image={post.featured_image_path
? imageUtils.getImageUrl(post.featured_image_path)
: '/images/placeholder.jpg'}
alt={post.title}
/>
<CardContent sx={{ flexGrow: 1 }}>
{/* Category */}
{post.category_name && (
<Chip
label={post.category_name}
size="small"
color="primary"
sx={{ mb: 1 }}
/>
)}
{/* Title */}
<Typography variant="h5" component="h2" gutterBottom>
{post.title}
</Typography>
{/* Published date */}
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
{formatPublishedDate(post.published_at)}
</Typography>
{/* Excerpt */}
<Typography variant="body2" color="text.secondary" paragraph>
{post.excerpt || (post.content && post.content.substring(0, 150) + '...')}
</Typography>
{/* Tags */}
<Box sx={{ mt: 2, mb: 1 }}>
{post.tags && post.tags.filter(Boolean).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
onClick={() => handleTagClick(tag)}
sx={{ mr: 0.5, mb: 0.5 }}
/>
))}
</Box>
</CardContent>
<CardActions>
<Button
size="small"
component={RouterLink}
to={`/blog/${post.slug}`}
>
Read More
</Button>
<Box sx={{ ml: 'auto' }}>
<Chip
label={`${post.comment_count || 0} comments`}
size="small"
variant="outlined"
/>
</Box>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{/* Pagination */}
{pagination.pages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<Pagination
count={pagination.pages}
page={pagination.page}
onChange={handlePageChange}
color="primary"
siblingCount={1}
/>
</Box>
)}
</Box>
</Container>
);
};
export default BlogPage;

View file

@ -24,7 +24,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@hooks/reduxHooks';
import { useGetCart, useUpdateCartItem, useClearCart, useProduct } from '@hooks/apiHooks';
import imageUtils from '@utils/imageUtils';
import CouponInput from '@components/CouponInput';
const CartPage = () => {
const navigate = useNavigate();
const { user } = useAuth();
@ -264,6 +264,9 @@ const CartPage = () => {
{/* Order summary */}
<Grid item xs={12} lg={4}>
{/* Coupon Input */}
<CouponInput />
<Paper variant="outlined" sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Order Summary
@ -278,10 +281,26 @@ const CartPage = () => {
</Grid>
<Grid item xs={4} sx={{ textAlign: 'right' }}>
<Typography variant="body1">
${cart.total.toFixed(2)}
${cart.subtotal?.toFixed(2) || cart.total.toFixed(2)}
</Typography>
</Grid>
{/* Display discount if coupon is applied */}
{cart.couponDiscount > 0 && (
<>
<Grid item xs={8}>
<Typography variant="body1" color="success.main">
Discount ({cart.couponCode})
</Typography>
</Grid>
<Grid item xs={4} sx={{ textAlign: 'right' }}>
<Typography variant="body1" color="success.main">
-${cart.couponDiscount.toFixed(2)}
</Typography>
</Grid>
</>
)}
<Grid item xs={8}>
<Typography variant="body1">
Shipping

View file

@ -0,0 +1,596 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
TextField,
MenuItem,
Select,
FormControl,
InputLabel,
FormHelperText,
Switch,
FormControlLabel,
Grid,
Button,
IconButton,
CircularProgress,
Alert,
Divider,
InputAdornment,
Autocomplete,
Chip,
Breadcrumbs,
Link
} from '@mui/material';
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Clear as ClearIcon
} from '@mui/icons-material';
import { useNavigate, useParams, Link as RouterLink } from 'react-router-dom';
import { useAdminCoupon, useCreateCoupon, useUpdateCoupon } from '@hooks/couponAdminHooks';
import { useCategories, useTags } from '@hooks/apiHooks';
import { format, parseISO } from 'date-fns';
const CouponEditPage = () => {
const navigate = useNavigate();
const { id } = useParams();
const isNewCoupon = !id;
// Form state
const [formData, setFormData] = useState({
code: '',
description: '',
discountType: 'percentage', // 'percentage' or 'fixed_amount'
discountValue: '',
minPurchaseAmount: '',
maxDiscountAmount: '',
redemptionLimit: '',
startDate: null,
endDate: null,
isActive: true,
categories: [],
tags: [],
blacklistedProducts: []
});
// Validation state
const [errors, setErrors] = useState({});
// Notification state
const [notification, setNotification] = useState({
open: false,
message: '',
severity: 'success'
});
// Fetch coupon data for editing
const { data: coupon, isLoading: couponLoading, error: couponError } = useAdminCoupon(
isNewCoupon ? null : id
);
// Mutations
const createCoupon = useCreateCoupon();
const updateCoupon = useUpdateCoupon();
// Fetch categories and tags
const { data: categories } = useCategories();
const { data: tags } = useTags();
// Set form data when editing existing coupon
useEffect(() => {
if (!isNewCoupon && coupon) {
setFormData({
code: coupon.code || '',
description: coupon.description || '',
discountType: coupon.discount_type || 'percentage',
discountValue: coupon.discount_value?.toString() || '',
minPurchaseAmount: coupon.min_purchase_amount?.toString() || '',
maxDiscountAmount: coupon.max_discount_amount?.toString() || '',
redemptionLimit: coupon.redemption_limit?.toString() || '',
startDate: coupon.start_date ? parseISO(coupon.start_date) : null,
endDate: coupon.end_date ? parseISO(coupon.end_date) : null,
isActive: coupon.is_active ?? true,
categories: coupon.categories || [],
tags: coupon.tags || [],
blacklistedProducts: coupon.blacklisted_products || []
});
}
}, [isNewCoupon, coupon]);
// Handle form changes
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
// Clear validation error when field is edited
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
// Handle date changes
const handleDateChange = (name, date) => {
setFormData(prev => ({
...prev,
[name]: date
}));
// Clear validation error when field is edited
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: null }));
}
};
// Handle categories change
const handleCategoriesChange = (event, newValue) => {
setFormData(prev => ({
...prev,
categories: newValue
}));
};
// Handle tags change
const handleTagsChange = (event, newValue) => {
setFormData(prev => ({
...prev,
tags: newValue
}));
};
// Validate form
const validateForm = () => {
const newErrors = {};
// Required fields
if (!formData.code) {
newErrors.code = 'Coupon code is required';
} else if (!/^[A-Za-z0-9_-]+$/.test(formData.code)) {
newErrors.code = 'Code can only contain letters, numbers, underscores, and hyphens';
}
if (!formData.discountType) {
newErrors.discountType = 'Discount type is required';
}
if (!formData.discountValue) {
newErrors.discountValue = 'Discount value is required';
} else if (isNaN(formData.discountValue) || parseFloat(formData.discountValue) <= 0) {
newErrors.discountValue = 'Discount value must be a positive number';
} else if (formData.discountType === 'percentage' && parseFloat(formData.discountValue) > 100) {
newErrors.discountValue = 'Percentage discount cannot exceed 100%';
}
// Optional numeric fields
if (formData.minPurchaseAmount && (isNaN(formData.minPurchaseAmount) || parseFloat(formData.minPurchaseAmount) < 0)) {
newErrors.minPurchaseAmount = 'Minimum purchase amount must be a non-negative number';
}
if (formData.maxDiscountAmount && (isNaN(formData.maxDiscountAmount) || parseFloat(formData.maxDiscountAmount) <= 0)) {
newErrors.maxDiscountAmount = 'Maximum discount amount must be a positive number';
}
if (formData.redemptionLimit && (isNaN(formData.redemptionLimit) || parseInt(formData.redemptionLimit) <= 0 || !Number.isInteger(parseFloat(formData.redemptionLimit)))) {
newErrors.redemptionLimit = 'Redemption limit must be a positive integer';
}
// Date validation
if (formData.startDate && formData.endDate && formData.startDate > formData.endDate) {
newErrors.endDate = 'End date must be after start date';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
setNotification({
open: true,
message: 'Please fix the form errors before submitting',
severity: 'error'
});
return;
}
// Format date fields
const formattedData = {
...formData,
code: formData.code.toUpperCase(),
discountValue: parseFloat(formData.discountValue),
minPurchaseAmount: formData.minPurchaseAmount ? parseFloat(formData.minPurchaseAmount) : null,
maxDiscountAmount: formData.maxDiscountAmount ? parseFloat(formData.maxDiscountAmount) : null,
redemptionLimit: formData.redemptionLimit ? parseInt(formData.redemptionLimit) : null,
startDate: formData.startDate ? format(formData.startDate, "yyyy-MM-dd'T'HH:mm:ss") : null,
endDate: formData.endDate ? format(formData.endDate, "yyyy-MM-dd'T'HH:mm:ss") : null,
// Format arrays for API
categories: formData.categories.map(cat => cat.id),
tags: formData.tags.map(tag => tag.id),
blacklistedProducts: formData.blacklistedProducts.map(prod => prod.id)
};
try {
if (isNewCoupon) {
await createCoupon.mutateAsync(formattedData);
setNotification({
open: true,
message: 'Coupon created successfully',
severity: 'success'
});
// Navigate after successful creation
setTimeout(() => {
navigate('/admin/coupons');
}, 1500);
} else {
await updateCoupon.mutateAsync({
id,
couponData: formattedData
});
setNotification({
open: true,
message: 'Coupon updated successfully',
severity: 'success'
});
}
} catch (error) {
setNotification({
open: true,
message: error.message || 'Error saving coupon',
severity: 'error'
});
}
};
// Handle notification close
const handleNotificationClose = () => {
setNotification(prev => ({ ...prev, open: false }));
};
// Loading state
if ((!isNewCoupon && couponLoading) || (!categories || !tags)) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
if (!isNewCoupon && couponError) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading coupon: {couponError.message}
</Alert>
);
}
return (
<Box>
<Box sx={{ mb: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin/coupons')}
sx={{ mb: 2 }}
>
Back to Coupons
</Button>
<Breadcrumbs sx={{ mb: 2 }}>
<Link component={RouterLink} to="/admin" color="inherit">
Admin
</Link>
<Link component={RouterLink} to="/admin/coupons" color="inherit">
Coupons
</Link>
<Typography color="text.primary">
{isNewCoupon ? 'Create Coupon' : 'Edit Coupon'}
</Typography>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>
{isNewCoupon ? 'Create New Coupon' : `Edit Coupon: ${coupon?.code || "Enter Code"}`}
</Typography>
</Box>
{/* Form */}
<Paper component="form" onSubmit={handleSubmit} sx={{ p: 3 }}>
{notification.open && (
<Alert
severity={notification.severity}
sx={{ mb: 3 }}
onClose={handleNotificationClose}
>
{notification.message}
</Alert>
)}
<Grid container spacing={3}>
{/* Basic Information */}
<Grid item xs={12}>
<Typography variant="h6" gutterBottom>
Basic Information
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Coupon Code"
name="code"
value={formData.code}
onChange={handleChange}
error={!!errors.code}
helperText={errors.code || 'Use uppercase letters, numbers, and hyphens'}
inputProps={{ style: { textTransform: 'uppercase' } }}
disabled={!isNewCoupon} // Cannot edit code for existing coupons
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={handleChange}
name="isActive"
color="primary"
/>
}
label={formData.isActive ? 'Coupon is Active' : 'Coupon is Inactive'}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Description"
name="description"
value={formData.description}
onChange={handleChange}
multiline
rows={2}
placeholder="Optional description of the coupon and its usage"
/>
</Grid>
{/* Discount Settings */}
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="h6" gutterBottom>
Discount Settings
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth required error={!!errors.discountType}>
<InputLabel id="discount-type-label">Discount Type</InputLabel>
<Select
labelId="discount-type-label"
name="discountType"
value={formData.discountType}
label="Discount Type"
onChange={handleChange}
>
<MenuItem value="percentage">Percentage</MenuItem>
<MenuItem value="fixed_amount">Fixed Amount</MenuItem>
</Select>
{errors.discountType && <FormHelperText>{errors.discountType}</FormHelperText>}
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
required
label="Discount Value"
name="discountValue"
type="number"
value={formData.discountValue}
onChange={handleChange}
error={!!errors.discountValue}
helperText={errors.discountValue}
InputProps={{
startAdornment: (
<InputAdornment position="start">
{formData.discountType === 'percentage' ? '%' : ''}
</InputAdornment>
),
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Minimum Purchase Amount"
name="minPurchaseAmount"
type="number"
value={formData.minPurchaseAmount}
onChange={handleChange}
error={!!errors.minPurchaseAmount}
helperText={errors.minPurchaseAmount || 'Minimum cart total to use this coupon (optional)'}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Maximum Discount Amount"
name="maxDiscountAmount"
type="number"
value={formData.maxDiscountAmount}
onChange={handleChange}
error={!!errors.maxDiscountAmount}
helperText={errors.maxDiscountAmount || 'Maximum discount for percentage coupons (optional)'}
InputProps={{
startAdornment: <InputAdornment position="start">$</InputAdornment>,
}}
disabled={formData.discountType !== 'percentage'}
/>
</Grid>
{/* Validity and Usage */}
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="h6" gutterBottom>
Validity and Usage Limits
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Start Date (Optional)"
name="startDate"
type="date"
value={formData.startDate}
onChange={handleChange}
error={!!errors.startDate}
helperText={errors.startDate || 'When this coupon becomes valid'}
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="End Date (Optional)"
name="endDate"
type="date"
value={formData.endDate}
onChange={handleChange}
error={!!errors.endDate}
helperText={errors.endDate || 'When this coupon expires'}
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Redemption Limit"
name="redemptionLimit"
type="number"
value={formData.redemptionLimit}
onChange={handleChange}
error={!!errors.redemptionLimit}
helperText={errors.redemptionLimit || 'How many times this coupon can be used (optional, leave empty for unlimited)'}
/>
</Grid>
{/* Applicable Categories and Tags */}
<Grid item xs={12}>
<Divider sx={{ my: 1 }} />
<Typography variant="h6" gutterBottom>
Applicable Products
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Specify which categories or tags this coupon applies to. If none selected, coupon applies to all products.
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Autocomplete
multiple
options={categories || []}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={formData.categories}
onChange={handleCategoriesChange}
renderInput={(params) => (
<TextField
{...params}
label="Applicable Categories"
placeholder="Select categories"
/>
)}
renderTags={(selected, getTagProps) =>
selected.map((category, index) => (
<Chip
label={category.name}
{...getTagProps({ index })}
key={category.id}
/>
))
}
/>
</Grid>
<Grid item xs={12} md={6}>
<Autocomplete
multiple
options={tags || []}
getOptionLabel={(option) => option.name}
isOptionEqualToValue={(option, value) => option.id === value.id}
value={formData.tags}
onChange={handleTagsChange}
renderInput={(params) => (
<TextField
{...params}
label="Applicable Tags"
placeholder="Select tags"
/>
)}
renderTags={(selected, getTagProps) =>
selected.map((tag, index) => (
<Chip
label={tag.name}
{...getTagProps({ index })}
key={tag.id}
/>
))
}
/>
</Grid>
{/* Submit Button */}
<Grid item xs={12} sx={{ mt: 3 }}>
<Button
type="submit"
variant="contained"
color="primary"
startIcon={
createCoupon.isLoading || updateCoupon.isLoading
? <CircularProgress size={24} color="inherit" />
: <SaveIcon />
}
disabled={createCoupon.isLoading || updateCoupon.isLoading}
>
{isNewCoupon ? 'Create Coupon' : 'Update Coupon'}
</Button>
<Button
variant="outlined"
sx={{ ml: 2 }}
onClick={() => navigate('/admin/coupons')}
>
Cancel
</Button>
</Grid>
</Grid>
</Paper>
</Box>
);
};
export default CouponEditPage;

View file

@ -0,0 +1,215 @@
import React, { useState } from 'react';
import {
Box,
Typography,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
Chip,
CircularProgress,
Alert,
Button,
Breadcrumbs,
Link
} from '@mui/material';
import { useParams, useNavigate, Link as RouterLink } from 'react-router-dom';
import { ArrowBack as ArrowBackIcon } from '@mui/icons-material';
import { useCouponRedemptions, useAdminCoupon } from '@hooks/couponAdminHooks';
import { format } from 'date-fns';
const CouponRedemptionsPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
// Fetch coupon details
const { data: coupon, isLoading: couponLoading, error: couponError } = useAdminCoupon(id);
// Fetch redemption history
const { data: redemptions, isLoading: redemptionsLoading, error: redemptionsError } = useCouponRedemptions(id);
// Handle page change
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
// Handle rows per page change
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
// Format date
const formatDate = (dateString) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy h:mm a');
} catch (error) {
return dateString;
}
};
// Loading state
const isLoading = couponLoading || redemptionsLoading;
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
);
}
// Error state
const error = couponError || redemptionsError;
if (error) {
return (
<Alert severity="error" sx={{ my: 2 }}>
Error loading data: {error.message}
</Alert>
);
}
// Paginate redemptions
const paginatedRedemptions = redemptions ? redemptions.slice(
page * rowsPerPage,
page * rowsPerPage + rowsPerPage
) : [];
return (
<Box>
<Box sx={{ mb: 3 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin/coupons')}
sx={{ mb: 2 }}
>
Back to Coupons
</Button>
<Breadcrumbs sx={{ mb: 2 }}>
<Link component={RouterLink} to="/admin" color="inherit">
Admin
</Link>
<Link component={RouterLink} to="/admin/coupons" color="inherit">
Coupons
</Link>
<Typography color="text.primary">Redemptions</Typography>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>
Redemption History: {coupon?.code}
</Typography>
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Coupon Details
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
<Box>
<Typography variant="subtitle2" color="text.secondary">Code</Typography>
<Typography variant="body1">{coupon?.code}</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Discount</Typography>
<Typography variant="body1">
{coupon?.discount_type === 'percentage'
? `${coupon.discount_value}%`
: `$${parseFloat(coupon.discount_value).toFixed(2)}`}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Usage</Typography>
<Typography variant="body1">
{coupon?.current_redemptions} / {coupon?.redemption_limit || 'Unlimited'}
</Typography>
</Box>
<Box>
<Typography variant="subtitle2" color="text.secondary">Status</Typography>
<Chip
label={coupon?.is_active ? 'Active' : 'Inactive'}
color={coupon?.is_active ? 'success' : 'default'}
size="small"
/>
</Box>
</Box>
</Paper>
</Box>
{/* Redemptions Table */}
<Paper>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Customer</TableCell>
<TableCell>Date</TableCell>
<TableCell>Order #</TableCell>
<TableCell align="right">Order Total</TableCell>
<TableCell align="right">Discount Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedRedemptions.length > 0 ? (
paginatedRedemptions.map((redemption) => (
<TableRow key={redemption.id}>
<TableCell>
<Typography variant="body2">
{redemption.first_name} {redemption.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{redemption.email}
</Typography>
</TableCell>
<TableCell>{formatDate(redemption.redeemed_at)}</TableCell>
<TableCell>
<Link
component={RouterLink}
to={`/admin/orders/${redemption.order_id}`}
>
{redemption.order_id.substring(0, 8)}...
</Link>
</TableCell>
<TableCell align="right">
${parseFloat(redemption.total_amount).toFixed(2)}
</TableCell>
<TableCell align="right">
${parseFloat(redemption.discount_amount).toFixed(2)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography variant="body1" py={2}>
No redemptions found for this coupon.
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{redemptions && redemptions.length > 0 && (
<TablePagination
rowsPerPageOptions={[5, 10, 25, 50]}
component="div"
count={redemptions.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
)}
</Paper>
</Box>
);
};
export default CouponRedemptionsPage;

View file

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

View file

@ -26,9 +26,11 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AddIcon from '@mui/icons-material/Add';
import RemoveIcon from '@mui/icons-material/Remove';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Rating } from '@mui/material';
import ProductReviews from '@components/ProductReviews';
import { Link as RouterLink } from 'react-router-dom';
import { useProduct, useAddToCart } from '../hooks/apiHooks';
import { useAuth } from '../hooks/reduxHooks';
import { useProduct, useAddToCart } from '@hooks/apiHooks';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
const ProductDetailPage = () => {
@ -173,6 +175,26 @@ const ProductDetailPage = () => {
{product.category_name}
</Link>
<Typography color="text.primary">{product.name}</Typography>
{product.average_rating && (
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
<Rating
value={product.average_rating}
readOnly
precision={0.5}
/>
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
</Typography>
</Box>
)}
{!product.average_rating && (
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
No reviews yet
</Typography>
</Box>
)}
</Breadcrumbs>
<Grid container spacing={4}>
@ -349,6 +371,10 @@ const ProductDetailPage = () => {
))}
</TableBody>
</Table>
<Grid item xs={12}>
<Divider sx={{ my: 4 }} />
<ProductReviews productId={id} />
</Grid>
</TableContainer>
</Grid>
</Grid>

View file

@ -31,15 +31,18 @@ import SortIcon from '@mui/icons-material/Sort';
import CloseIcon from '@mui/icons-material/Close';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
import { useProducts, useCategories, useTags, useAddToCart } from '../hooks/apiHooks';
import { useAuth } from '../hooks/reduxHooks';
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
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);
@ -138,7 +141,7 @@ const ProductsPage = () => {
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Products
{brandingSettings?.product_title || `Products`}
</Typography>
{/* Search and filter bar */}
@ -155,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={{
@ -362,7 +365,10 @@ const ProductsPage = () => {
>
{product.name}
</Typography>
<ProductRatingDisplay
rating={product.average_rating}
reviewCount={product.review_count}
/>
<Typography
variant="body2"
color="text.secondary"

View file

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

View file

@ -0,0 +1,160 @@
import apiClient from './api';
const blogAdminService = {
/**
* Get all blog posts (admin)
* @returns {Promise} Promise with the API response
*/
getAllPosts: async () => {
try {
const response = await apiClient.get('/admin/blog');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get a single blog post for editing (admin)
* @param {string} id - Blog post ID
* @returns {Promise} Promise with the API response
*/
getPostById: async (id) => {
try {
const response = await apiClient.get(`/admin/blog/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Create a new blog post (admin)
* @param {Object} postData - Blog post data
* @returns {Promise} Promise with the API response
*/
createPost: async (postData) => {
try {
const response = await apiClient.post('/admin/blog', postData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Update a blog post (admin)
* @param {string} id - Blog post ID
* @param {Object} postData - Updated blog post data
* @returns {Promise} Promise with the API response
*/
updatePost: async (id, postData) => {
try {
const response = await apiClient.put(`/admin/blog/${id}`, postData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a blog post (admin)
* @param {string} id - Blog post ID
* @returns {Promise} Promise with the API response
*/
deletePost: async (id) => {
try {
const response = await apiClient.delete(`/admin/blog/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Upload an image for a blog post (admin)
* @param {string} postId - Blog post ID
* @param {Object} imageData - Image data to upload
* @returns {Promise} Promise with the API response
*/
uploadImage: async (postId, imageData) => {
try {
const response = await apiClient.post(`/admin/blog/${postId}/images`, imageData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete an image from a blog post (admin)
* @param {string} postId - Blog post ID
* @param {string} imageId - Image ID
* @returns {Promise} Promise with the API response
*/
deleteImage: async (postId, imageId) => {
try {
const response = await apiClient.delete(`/admin/blog/${postId}/images/${imageId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all pending comments (admin)
* @returns {Promise} Promise with the API response
*/
getPendingComments: async () => {
try {
const response = await apiClient.get('/admin/blog-comments/pending');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all comments for a post (admin)
* @param {string} postId - Blog post ID
* @returns {Promise} Promise with the API response
*/
getPostComments: async (postId) => {
try {
const response = await apiClient.get(`/admin/blog-comments/posts/${postId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Approve a comment (admin)
* @param {string} commentId - Comment ID
* @returns {Promise} Promise with the API response
*/
approveComment: async (commentId) => {
try {
const response = await apiClient.post(`/admin/blog-comments/${commentId}/approve`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a comment (admin)
* @param {string} commentId - Comment ID
* @returns {Promise} Promise with the API response
*/
deleteComment: async (commentId) => {
try {
const response = await apiClient.delete(`/admin/blog-comments/${commentId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default blogAdminService;

View file

@ -0,0 +1,62 @@
import apiClient from './api';
const blogService = {
/**
* Get all published blog posts with optional filtering
* @param {Object} params - Query parameters for filtering posts
* @returns {Promise} Promise with the API response
*/
getAllPosts: async (params = {}) => {
try {
const response = await apiClient.get('/blog', { params });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get a single blog post by slug
* @param {string} slug - Blog post slug
* @returns {Promise} Promise with the API response
*/
getPostBySlug: async (slug) => {
try {
const response = await apiClient.get(`/blog/${slug}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all blog categories
* @returns {Promise} Promise with the API response
*/
getAllCategories: async () => {
try {
const response = await apiClient.get('/blog/categories/all');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Add a comment to a blog post
* @param {string} postId - Blog post ID
* @param {Object} commentData - Comment data to submit
* @param {string} commentData.userId - User ID
* @returns {Promise} Promise with the API response
*/
addComment: async (postId, commentData) => {
try {
const response = await apiClient.post(`/blog/${postId}/comments`, commentData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default blogService;

View file

@ -0,0 +1,133 @@
import apiClient from './api';
const couponService = {
/**
* Get all coupons (admin only)
* @returns {Promise} Promise with the API response
*/
getAllCoupons: async () => {
try {
const response = await apiClient.get('/admin/coupons');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get a single coupon by ID (admin only)
* @param {string} id - Coupon ID
* @returns {Promise} Promise with the API response
*/
getCouponById: async (id) => {
try {
const response = await apiClient.get(`/admin/coupons/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Create a new coupon (admin only)
* @param {Object} couponData - Coupon data
* @returns {Promise} Promise with the API response
*/
createCoupon: async (couponData) => {
try {
const response = await apiClient.post('/admin/coupons', couponData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Update a coupon (admin only)
* @param {string} id - Coupon ID
* @param {Object} couponData - Updated coupon data
* @returns {Promise} Promise with the API response
*/
updateCoupon: async (id, couponData) => {
try {
const response = await apiClient.put(`/admin/coupons/${id}`, couponData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a coupon (admin only)
* @param {string} id - Coupon ID
* @returns {Promise} Promise with the API response
*/
deleteCoupon: async (id) => {
try {
const response = await apiClient.delete(`/admin/coupons/${id}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Apply a coupon code to the cart
* @param {string} userId - User ID
* @param {string} code - Coupon code
* @returns {Promise} Promise with the API response
*/
applyCoupon: async (userId, code) => {
try {
const response = await apiClient.post(`/cart/apply-coupon`, { userId, code });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Remove a coupon from the cart
* @param {string} userId - User ID
* @returns {Promise} Promise with the API response
*/
removeCoupon: async (userId) => {
try {
const response = await apiClient.post(`/cart/remove-coupon`, { userId });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Validate a coupon code
* @param {string} code - Coupon code
* @param {number} cartTotal - Cart total amount
* @returns {Promise} Promise with the API response
*/
validateCoupon: async (code, cartTotal) => {
try {
const response = await apiClient.post('/coupons/validate', { code, cartTotal });
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get coupon redemption history (admin only)
* @param {string} id - Coupon ID
* @returns {Promise} Promise with the API response
*/
getCouponRedemptions: async (id) => {
try {
const response = await apiClient.get(`/admin/coupons/${id}/redemptions`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default couponService;

View file

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

View file

@ -0,0 +1,112 @@
import apiClient from './api';
const productReviewService = {
/**
* Get all reviews for a product
* @param {string} productId - Product ID
* @returns {Promise<Array>} - Array of reviews
*/
getProductReviews: async (productId) => {
try {
const response = await apiClient.get(`/product-reviews/${productId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Add a review to a product
* @param {string} productId - Product ID
* @param {Object} reviewData - Review data
* @param {string} reviewData.title - Review title
* @param {string} reviewData.content - Review content
* @param {number} reviewData.rating - Star rating (1-5)
* @param {string} [reviewData.parentId] - Parent review ID (for replies)
* @returns {Promise<Object>} - Response with the new review
*/
addProductReview: async (productId, reviewData) => {
try {
const response = await apiClient.post(`/product-reviews/${productId}`, reviewData);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Check if user can review a product
* @param {string} productId - Product ID
* @returns {Promise<Object>} - Object with canReview, isPurchaser, and isAdmin flags
*/
canReviewProduct: async (productId) => {
try {
const response = await apiClient.get(`/product-reviews/${productId}/can-review`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
/**
* Admin-specific review service functions
*/
export const productReviewAdminService = {
/**
* Get all pending reviews (admin only)
* @returns {Promise<Array>} - Array of pending reviews
*/
getPendingReviews: async () => {
try {
const response = await apiClient.get('/admin/product-reviews/pending');
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Get all reviews for a product (admin only)
* @param {string} productId - Product ID
* @returns {Promise<Object>} - Object with product and reviews
*/
getProductReviews: async (productId) => {
try {
const response = await apiClient.get(`/admin/product-reviews/products/${productId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Approve a review (admin only)
* @param {string} reviewId - Review ID
* @returns {Promise<Object>} - Response with the approved review
*/
approveReview: async (reviewId) => {
try {
const response = await apiClient.post(`/admin/product-reviews/${reviewId}/approve`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
},
/**
* Delete a review (admin only)
* @param {string} reviewId - Review ID
* @returns {Promise<Object>} - Response with success message
*/
deleteReview: async (reviewId) => {
try {
const response = await apiClient.delete(`/admin/product-reviews/${reviewId}`);
return response.data;
} catch (error) {
throw error.response?.data || { message: 'An unknown error occurred' };
}
}
};
export default productReviewService;

View file

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