Compare commits
2 commits
b1f5985224
...
f10ed6bf08
| Author | SHA1 | Date | |
|---|---|---|---|
| f10ed6bf08 | |||
| 37da2acb5d |
68 changed files with 10221 additions and 3454 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -3,4 +3,4 @@ node_modules
|
|||
npm-debug.log
|
||||
yarn-error.log
|
||||
.DS_Store
|
||||
public/uploads/*
|
||||
backend/public/uploads/*
|
||||
|
|
@ -11,9 +11,8 @@ const settingsAdminRoutes = require('./routes/settingsAdmin');
|
|||
const SystemSettings = require('./models/SystemSettings');
|
||||
const fs = require('fs');
|
||||
// services
|
||||
|
||||
const notificationService = require('./services/notificationService');
|
||||
|
||||
const emailService = require('./services/emailService');
|
||||
|
||||
// routes
|
||||
const stripePaymentRoutes = require('./routes/stripePayment');
|
||||
|
|
@ -32,6 +31,8 @@ const blogAdminRoutes = require('./routes/blogAdmin');
|
|||
const blogCommentsAdminRoutes = require('./routes/blogCommentsAdmin');
|
||||
const productReviewsRoutes = require('./routes/productReviews');
|
||||
const productReviewsAdminRoutes = require('./routes/productReviewsAdmin');
|
||||
const emailTemplatesAdminRoutes = require('./routes/emailTemplatesAdmin');
|
||||
const publicSettingsRoutes = require('./routes/publicSettings');
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
|
@ -180,6 +181,8 @@ app.get('/health', (req, res) => {
|
|||
res.status(200).json({ status: 'ok', message: 'API is running' });
|
||||
});
|
||||
|
||||
app.use('/api/settings', publicSettingsRoutes(pool, query));
|
||||
|
||||
// Upload endpoints
|
||||
// Public upload endpoint (basic)
|
||||
app.post('/api/image/upload', upload.single('image'), (req, res) => {
|
||||
|
|
@ -197,6 +200,21 @@ app.post('/api/image/upload', upload.single('image'), (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.get('/api/public-file/:filename', (req, res) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
// Prevent path traversal attacks
|
||||
if (filename.includes('..') || filename.includes('/')) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Invalid filename'
|
||||
});
|
||||
}
|
||||
|
||||
// Serve files from public uploads folder
|
||||
res.sendFile(path.join(__dirname, '../public/uploads', filename));
|
||||
});
|
||||
|
||||
app.use('/api/product-reviews', productReviewsRoutes(pool, query, authMiddleware(pool, query)));
|
||||
app.use('/api/admin/product-reviews', productReviewsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
|
||||
|
|
@ -205,6 +223,7 @@ app.use('/api/admin/coupons', couponsAdminRoutes(pool, query, adminAuthMiddlewar
|
|||
app.use('/api/admin/orders', ordersAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/blog', blogAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/blog-comments', blogCommentsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||
app.use('/api/admin/email-templates', emailTemplatesAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); // Add new route
|
||||
|
||||
// Admin-only product image upload
|
||||
app.post('/api/image/product', adminAuthMiddleware(pool, query), upload.single('image'), (req, res) => {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ module.exports = (pool, query) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!result.rows[0].is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -38,7 +38,6 @@ module.exports = (pool, query) => {
|
|||
// Add user to request object
|
||||
req.user = result.rows[0];
|
||||
|
||||
// Continue to next middleware/route handler
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ module.exports = (pool, query) => {
|
|||
// Add user to request object
|
||||
req.user = result.rows[0];
|
||||
|
||||
// Continue to next middleware/route handler
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ const fileFilter = (req, file, cb) => {
|
|||
}
|
||||
};
|
||||
|
||||
// Create the multer instance
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,9 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
const emailService = require('../services/emailService');
|
||||
|
||||
const router = express.Router();
|
||||
const createTransporter = () => {
|
||||
return nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.pass
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const transporter = createTransporter();
|
||||
|
||||
module.exports = (pool, query) => {
|
||||
// Register new user
|
||||
|
|
@ -95,18 +83,15 @@ module.exports = (pool, query) => {
|
|||
|
||||
const loginLink = `${config.site.protocol}://${config.site.domain}/verify?code=${authCode}&email=${encodeURIComponent(email)}`;
|
||||
|
||||
|
||||
|
||||
await transporter.sendMail({
|
||||
from: 'noreply@2many.ca',
|
||||
to: email,
|
||||
subject: 'Your Login Code',
|
||||
html: `
|
||||
<h1>Your login code is: ${authCode}</h1>
|
||||
<p>This code will expire in 15 minutes.</p>
|
||||
<p>Or click <a href="${loginLink}">here</a> to log in directly.</p>
|
||||
`
|
||||
});
|
||||
try {
|
||||
await emailService.sendLoginCodeEmail({
|
||||
to: email,
|
||||
code: authCode,
|
||||
loginLink: loginLink
|
||||
});
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send login code email:', emailError);
|
||||
}
|
||||
let retObj = {
|
||||
message: 'Login code sent to address: ' + email
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,7 +152,6 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const commentThreads = [];
|
||||
const commentMap = {};
|
||||
|
||||
// First, create a map of all comments
|
||||
commentsResult.rows.forEach(comment => {
|
||||
commentMap[comment.id] = {
|
||||
...comment,
|
||||
|
|
@ -173,7 +172,6 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Return the post with images and comments
|
||||
res.json({
|
||||
...post,
|
||||
images: imagesResult.rows,
|
||||
|
|
@ -245,7 +243,6 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine if comment needs moderation
|
||||
const isApproved = req.user.is_admin ? true : false;
|
||||
|
||||
// Insert comment
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Get all blog posts (admin)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -47,7 +47,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -112,7 +112,6 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
tags, featuredImagePath, status, publishNow
|
||||
} = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -135,7 +134,6 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
remove: /[*+~.()'"!:@]/g // regex to remove characters
|
||||
});
|
||||
|
||||
// Check if slug already exists
|
||||
const slugCheck = await query(
|
||||
'SELECT id FROM blog_posts WHERE slug = $1',
|
||||
[slug]
|
||||
|
|
@ -221,7 +219,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
tags, featuredImagePath, status, publishNow
|
||||
} = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -365,7 +363,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -406,7 +404,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const { postId } = req.params;
|
||||
const { imagePath, caption, displayOrder } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -457,7 +455,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { postId, imageId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Get all pending comments (admin)
|
||||
router.get('/pending', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -41,7 +41,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { postId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -120,7 +120,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { commentId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -169,7 +169,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { commentId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
*/
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -68,7 +68,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -151,7 +151,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
blacklistedProducts
|
||||
} = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -343,7 +343,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
blacklistedProducts
|
||||
} = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -605,7 +605,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -648,7 +648,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
602
backend/src/routes/emailTemplatesAdmin.js
Normal file
602
backend/src/routes/emailTemplatesAdmin.js
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const router = express.Router();
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
|
||||
// Create email transporter
|
||||
const createTransporter = () => {
|
||||
return nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.pass
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
// Apply authentication middleware to all routes
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* Get all email templates
|
||||
* GET /api/admin/email-templates
|
||||
*/
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all settings with 'email_templates' category
|
||||
const result = await query(
|
||||
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Transform settings into template objects
|
||||
const templates = result.rows.map(setting => {
|
||||
try {
|
||||
// Parse the template data from the JSON value
|
||||
const templateData = JSON.parse(setting.value);
|
||||
return {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean); // Remove any null entries
|
||||
|
||||
res.json(templates);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get templates by type
|
||||
* GET /api/admin/email-templates/type/:type
|
||||
*/
|
||||
router.get('/type/:type', async (req, res, next) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all settings with 'email_templates' category
|
||||
const result = await query(
|
||||
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Transform settings into template objects and filter by type
|
||||
const templates = result.rows
|
||||
.map(setting => {
|
||||
try {
|
||||
// Parse the template data from the JSON value
|
||||
const templateData = JSON.parse(setting.value);
|
||||
if (templateData.type === type) {
|
||||
return {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean); // Remove any null entries
|
||||
|
||||
res.json(templates);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get default template for a type
|
||||
* GET /api/admin/email-templates/default/:type
|
||||
*/
|
||||
router.get('/default/:type', async (req, res, next) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all settings with 'email_templates' category
|
||||
const result = await query(
|
||||
'SELECT * FROM system_settings WHERE category = $1 ORDER BY key',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Find the default template for the specified type
|
||||
let defaultTemplate = null;
|
||||
|
||||
for (const setting of result.rows) {
|
||||
try {
|
||||
const templateData = JSON.parse(setting.value);
|
||||
if (templateData.type === type && templateData.isDefault) {
|
||||
defaultTemplate = {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultTemplate) {
|
||||
res.json(defaultTemplate);
|
||||
} else {
|
||||
res.status(404).json({
|
||||
error: true,
|
||||
message: `No default template found for type: ${type}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single template by ID
|
||||
* GET /api/admin/email-templates/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Get the setting by key
|
||||
const result = await query(
|
||||
'SELECT * FROM system_settings WHERE key = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the template data from the JSON value
|
||||
const templateData = JSON.parse(result.rows[0].value);
|
||||
res.json({
|
||||
id: result.rows[0].key,
|
||||
...templateData
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${id}`, e);
|
||||
return res.status(500).json({
|
||||
error: true,
|
||||
message: 'Failed to parse template data'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
* POST /api/admin/email-templates
|
||||
*/
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const { name, type, subject, content, isDefault } = req.body;
|
||||
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !type || !subject || !content) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Name, type, subject, and content are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction for potential default template updates
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Generate a unique key for the setting
|
||||
const templateKey = `email_template_${Date.now()}`;
|
||||
|
||||
// Create the template object
|
||||
const templateData = {
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
isDefault: isDefault || false,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// If this template should be the default, unset any existing defaults
|
||||
if (templateData.isDefault) {
|
||||
// Get all settings with 'email_templates' category
|
||||
const existingTemplates = await client.query(
|
||||
'SELECT * FROM system_settings WHERE category = $1',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Find and update any existing default templates of the same type
|
||||
for (const setting of existingTemplates.rows) {
|
||||
try {
|
||||
const existingData = JSON.parse(setting.value);
|
||||
if (existingData.type === type && existingData.isDefault) {
|
||||
existingData.isDefault = false;
|
||||
existingData.updatedAt = new Date().toISOString();
|
||||
|
||||
await client.query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
|
||||
[JSON.stringify(existingData), setting.key]
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the new template
|
||||
await client.query(
|
||||
'INSERT INTO system_settings (key, value, category) VALUES ($1, $2, $3)',
|
||||
[templateKey, JSON.stringify(templateData), 'email_templates']
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
id: templateKey,
|
||||
...templateData
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Update a template
|
||||
* PUT /api/admin/email-templates/:id
|
||||
*/
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, type, subject, content, isDefault } = req.body;
|
||||
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !type || !subject || !content) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Name, type, subject, and content are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction for potential default template updates
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Check if the template exists
|
||||
const templateCheck = await client.query(
|
||||
'SELECT * FROM system_settings WHERE key = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (templateCheck.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the existing template data
|
||||
let existingData;
|
||||
try {
|
||||
existingData = JSON.parse(templateCheck.rows[0].value);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(500).json({
|
||||
error: true,
|
||||
message: 'Failed to parse existing template data'
|
||||
});
|
||||
}
|
||||
|
||||
// Create the updated template object
|
||||
const templateData = {
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
isDefault: isDefault !== undefined ? isDefault : existingData.isDefault,
|
||||
createdAt: existingData.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// If this template should be the default, unset any existing defaults
|
||||
if (templateData.isDefault && (!existingData.isDefault || existingData.type !== type)) {
|
||||
// Get all settings with 'email_templates' category
|
||||
const existingTemplates = await client.query(
|
||||
'SELECT * FROM system_settings WHERE category = $1',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Find and update any existing default templates of the same type
|
||||
for (const setting of existingTemplates.rows) {
|
||||
if (setting.key === id) continue; // Skip the current template
|
||||
|
||||
try {
|
||||
const otherData = JSON.parse(setting.value);
|
||||
if (otherData.type === type && otherData.isDefault) {
|
||||
otherData.isDefault = false;
|
||||
otherData.updatedAt = new Date().toISOString();
|
||||
|
||||
await client.query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
|
||||
[JSON.stringify(otherData), setting.key]
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the template
|
||||
await client.query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = NOW() WHERE key = $2',
|
||||
[JSON.stringify(templateData), id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
id,
|
||||
...templateData
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a template
|
||||
* DELETE /api/admin/email-templates/:id
|
||||
*/
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the template exists and is not a default template
|
||||
const templateCheck = await query(
|
||||
'SELECT * FROM system_settings WHERE key = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (templateCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the template data to check if it's a default template
|
||||
try {
|
||||
const templateData = JSON.parse(templateCheck.rows[0].value);
|
||||
if (templateData.isDefault) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Cannot delete a default template. Please set another template as default first.'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${id}`, e);
|
||||
}
|
||||
|
||||
// Delete the template
|
||||
await query(
|
||||
'DELETE FROM system_settings WHERE key = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Template deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Send a test email using a template
|
||||
* POST /api/admin/email-templates/:id/test
|
||||
*/
|
||||
router.post('/:id/test', async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { email, variables } = req.body;
|
||||
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email address
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: 'Email address is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the template exists
|
||||
const templateCheck = await query(
|
||||
'SELECT * FROM system_settings WHERE key = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (templateCheck.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse the template data
|
||||
let templateData;
|
||||
try {
|
||||
templateData = JSON.parse(templateCheck.rows[0].value);
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${id}`, e);
|
||||
return res.status(500).json({
|
||||
error: true,
|
||||
message: 'Failed to parse template data'
|
||||
});
|
||||
}
|
||||
|
||||
// Replace variables in template
|
||||
let emailContent = templateData.content;
|
||||
let emailSubject = templateData.subject;
|
||||
|
||||
if (variables) {
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
emailContent = emailContent.replace(new RegExp(placeholder, 'g'), value);
|
||||
emailSubject = emailSubject.replace(new RegExp(placeholder, 'g'), value);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a transporter
|
||||
const transporter = createTransporter();
|
||||
|
||||
// Send the test email
|
||||
await transporter.sendMail({
|
||||
from: config.email.reply,
|
||||
to: email,
|
||||
subject: `[TEST] ${emailSubject}`,
|
||||
html: emailContent
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Test email sent to ${email}`
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Utility function to get a template by type
|
||||
* @param {string} type - Template type
|
||||
* @returns {Promise<Object|null>} Template object or null if not found
|
||||
*/
|
||||
async function getTemplateByType(type) {
|
||||
try {
|
||||
// Get all settings with 'email_templates' category
|
||||
const result = await query(
|
||||
'SELECT * FROM system_settings WHERE category = $1',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Find the default template for the specified type
|
||||
let defaultTemplate = null;
|
||||
|
||||
for (const setting of result.rows) {
|
||||
try {
|
||||
const templateData = JSON.parse(setting.value);
|
||||
if (templateData.type === type && templateData.isDefault) {
|
||||
defaultTemplate = {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return defaultTemplate;
|
||||
} catch (error) {
|
||||
console.error('Error getting template by type:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -1,19 +1,6 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
|
||||
// Helper function to create email transporter
|
||||
const createTransporter = () => {
|
||||
return nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.pass
|
||||
}
|
||||
});
|
||||
};
|
||||
const emailService = require('../services/emailService'); // Import email service
|
||||
|
||||
module.exports = (pool, query, authMiddleware) => {
|
||||
// Apply authentication middleware to all routes
|
||||
|
|
@ -22,7 +9,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Get all orders (admin only)
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -52,7 +39,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -124,7 +111,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -171,7 +158,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const { id } = req.params;
|
||||
const { status, shippingData, sendNotification } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -240,12 +227,66 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
|
||||
const orderItems = itemsResult.rows;
|
||||
|
||||
// Send email notification
|
||||
await sendShippingNotification(
|
||||
order,
|
||||
orderItems,
|
||||
shippingData
|
||||
);
|
||||
// Generate items HTML table
|
||||
const itemsHtml = orderItems.map(item => `
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Generate carrier tracking link
|
||||
let trackingLink = '#';
|
||||
const shipper = shippingData.shipper || '';
|
||||
const trackingNumber = shippingData.trackingNumber;
|
||||
|
||||
if (trackingNumber) {
|
||||
// Match exactly with the values from the dropdown
|
||||
switch(shipper) {
|
||||
case 'USPS':
|
||||
trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`;
|
||||
break;
|
||||
case 'UPS':
|
||||
trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`;
|
||||
break;
|
||||
case 'FedEx':
|
||||
trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`;
|
||||
break;
|
||||
case 'DHL':
|
||||
trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`;
|
||||
break;
|
||||
case 'Canada Post':
|
||||
trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`;
|
||||
break;
|
||||
case 'Purolator':
|
||||
trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`;
|
||||
break;
|
||||
default:
|
||||
// For "other" or any carrier not in our list
|
||||
// Just make the tracking number text without a link
|
||||
trackingLink = '#';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Format shipping date
|
||||
const shippedDate = new Date(shippingData.shippedDate || new Date()).toLocaleDateString();
|
||||
|
||||
// Send email notification using template system
|
||||
await emailService.sendShippingNotification({
|
||||
to: order.email,
|
||||
first_name: order.first_name,
|
||||
order_id: order.id.substring(0, 8),
|
||||
tracking_number: shippingData.trackingNumber || 'N/A',
|
||||
carrier: shippingData.shipper || 'Standard Shipping',
|
||||
tracking_link: trackingLink,
|
||||
shipped_date: shippedDate,
|
||||
estimated_delivery: shippingData.estimatedDelivery || 'N/A',
|
||||
items_html: itemsHtml,
|
||||
customer_message: shippingData.customerMessage || ''
|
||||
});
|
||||
|
||||
// Log the notification in the database
|
||||
await client.query(`
|
||||
|
|
@ -272,135 +313,5 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Helper function to send shipping notification email
|
||||
async function sendShippingNotification(order, orderItems, shippingData) {
|
||||
try {
|
||||
const transporter = createTransporter();
|
||||
|
||||
// Calculate order total
|
||||
const orderTotal = orderItems.reduce((sum, item) => {
|
||||
return sum + (parseFloat(item.price_at_purchase) * item.quantity);
|
||||
}, 0);
|
||||
|
||||
// Format shipping date
|
||||
const shippedDate = new Date(shippingData.shippedDate || new Date()).toLocaleDateString();
|
||||
|
||||
// Generate items HTML table
|
||||
const itemsHtml = orderItems.map(item => `
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.product_name}</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">${item.quantity}</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${parseFloat(item.price_at_purchase).toFixed(2)}</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$${(parseFloat(item.price_at_purchase) * item.quantity).toFixed(2)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
// Generate carrier tracking link
|
||||
let trackingLink = '#';
|
||||
const shipper = shippingData.shipper || '';
|
||||
const trackingNumber = shippingData.trackingNumber;
|
||||
|
||||
if (trackingNumber) {
|
||||
// Match exactly with the values from the dropdown
|
||||
switch(shipper) {
|
||||
case 'USPS':
|
||||
trackingLink = `https://tools.usps.com/go/TrackConfirmAction?tLabels=${trackingNumber}`;
|
||||
break;
|
||||
case 'UPS':
|
||||
trackingLink = `https://www.ups.com/track?tracknum=${trackingNumber}`;
|
||||
break;
|
||||
case 'FedEx':
|
||||
trackingLink = `https://www.fedex.com/apps/fedextrack/?tracknumbers=${trackingNumber}`;
|
||||
break;
|
||||
case 'DHL':
|
||||
trackingLink = `https://www.dhl.com/global-en/home/tracking.html?tracking-id=${trackingNumber}`;
|
||||
break;
|
||||
case 'Canada Post':
|
||||
trackingLink = `https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=${trackingNumber}`;
|
||||
break;
|
||||
case 'Purolator':
|
||||
trackingLink = `https://www.purolator.com/en/shipping/track/tracking-number/${trackingNumber}`;
|
||||
break;
|
||||
default:
|
||||
// For "other" or any carrier not in our list
|
||||
// Just make the tracking number text without a link
|
||||
trackingLink = '#';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build email HTML
|
||||
const emailHtml = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background-color: #f8f8f8; padding: 20px; text-align: center;">
|
||||
<h1 style="color: #333;">Your Order Has Shipped!</h1>
|
||||
<p style="font-size: 16px;">Order #${order.id.substring(0, 8)}</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<p>Hello ${order.first_name},</p>
|
||||
|
||||
<p>Good news! Your order has been shipped and is on its way to you.</p>
|
||||
|
||||
${shippingData.customerMessage ? `<p><strong>Message from our team:</strong> ${shippingData.customerMessage}</p>` : ''}
|
||||
|
||||
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #4caf50;">
|
||||
<h3 style="margin-top: 0;">Shipping Details</h3>
|
||||
<p><strong>Carrier:</strong> ${shippingData.shipper || 'Standard Shipping'}</p>
|
||||
<p><strong>Tracking Number:</strong> <a href="${trackingLink}" target="_blank">${shippingData.trackingNumber}</a></p>
|
||||
<p><strong>Shipped On:</strong> ${shippedDate}</p>
|
||||
${shippingData.estimatedDelivery ? `<p><strong>Estimated Delivery:</strong> ${shippingData.estimatedDelivery}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px;">
|
||||
<h3>Order Summary</h3>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background-color: #f2f2f2;">
|
||||
<th style="padding: 10px; text-align: left;">Item</th>
|
||||
<th style="padding: 10px; text-align: left;">Qty</th>
|
||||
<th style="padding: 10px; text-align: left;">Price</th>
|
||||
<th style="padding: 10px; text-align: left;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${itemsHtml}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" style="padding: 10px; text-align: right;"><strong>Total:</strong></td>
|
||||
<td style="padding: 10px;"><strong>$${orderTotal.toFixed(2)}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
|
||||
<p>Thank you for your purchase! If you have any questions, please contact our customer service.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;">
|
||||
<p>© ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Send the email
|
||||
await transporter.sendMail({
|
||||
from: config.email.reply,
|
||||
to: order.email,
|
||||
subject: `Your Order #${order.id.substring(0, 8)} Has Shipped!`,
|
||||
html: emailHtml
|
||||
});
|
||||
|
||||
console.log(`Shipping notification email sent to ${order.email}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending shipping notification:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -157,7 +157,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const { id } = req.params;
|
||||
const { enabled, email, threshold } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
|
||||
const isVerifiedPurchase = purchaseCheck.rows.length > 0;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
const isAdmin = req.user.is_admin || false;
|
||||
|
||||
// Only allow reviews if user has purchased the product or is an admin
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Get all pending reviews (admin)
|
||||
router.get('/pending', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -41,7 +41,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { productId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -120,7 +120,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { reviewId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -169,7 +169,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { reviewId } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
52
backend/src/routes/publicSettings.js
Normal file
52
backend/src/routes/publicSettings.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const SystemSettings = require('../models/SystemSettings');
|
||||
|
||||
module.exports = (pool, query) => {
|
||||
/**
|
||||
* Get public branding settings
|
||||
* GET /api/settings/branding
|
||||
*/
|
||||
router.get('/branding', async (req, res, next) => {
|
||||
try {
|
||||
// Get all settings with 'branding' category
|
||||
const settings = await SystemSettings.getSettingsByCategory(pool, query, 'branding');
|
||||
|
||||
// Convert array of settings to an object for easier client-side use
|
||||
const brandingSettings = {};
|
||||
settings.forEach(setting => {
|
||||
brandingSettings[setting.key] = setting.value;
|
||||
});
|
||||
|
||||
res.json(brandingSettings);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get basic public settings (for meta tags, favicon, etc.)
|
||||
* GET /api/settings/meta
|
||||
*/
|
||||
router.get('/meta', async (req, res, next) => {
|
||||
try {
|
||||
// Get relevant settings
|
||||
const siteNameSetting = await SystemSettings.getSetting(pool, query, 'site_name');
|
||||
const siteDescriptionSetting = await SystemSettings.getSetting(pool, query, 'site_description');
|
||||
const faviconSetting = await SystemSettings.getSetting(pool, query, 'favicon_url');
|
||||
|
||||
// Create response object
|
||||
const metaSettings = {
|
||||
siteName: siteNameSetting?.value || 'Rocks, Bones & Sticks',
|
||||
siteDescription: siteDescriptionSetting?.value || '',
|
||||
faviconUrl: faviconSetting?.value || ''
|
||||
};
|
||||
|
||||
res.json(metaSettings);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
|
@ -14,7 +14,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
*/
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -46,7 +46,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { category } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -68,7 +68,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { key } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -99,7 +99,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const { key } = req.params;
|
||||
const { value, category } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -138,7 +138,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { settings } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -189,7 +189,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { key } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
// Get all users
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -53,7 +53,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -95,7 +95,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
const { id } = req.params;
|
||||
const { is_disabled, internal_notes, is_admin} = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
@ -143,7 +143,7 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
try {
|
||||
const { to, name, subject, message } = req.body;
|
||||
|
||||
// Check if user is admin
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
|
|
|
|||
278
backend/src/services/emailService.js
Normal file
278
backend/src/services/emailService.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
const { query, pool } = require('../db');
|
||||
|
||||
/**
|
||||
* Service for sending emails with templates
|
||||
*/
|
||||
const emailService = {
|
||||
/**
|
||||
* Create email transporter
|
||||
* @returns {Object} Configured nodemailer transporter
|
||||
*/
|
||||
createTransporter() {
|
||||
return nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.pass
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a template by type, preferring the default one
|
||||
* @param {string} type - Template type
|
||||
* @returns {Promise<Object|null>} Template object or null if not found
|
||||
*/
|
||||
async getTemplateByType(type) {
|
||||
try {
|
||||
// Get all settings with 'email_templates' category
|
||||
const result = await query(
|
||||
'SELECT * FROM system_settings WHERE category = $1',
|
||||
['email_templates']
|
||||
);
|
||||
|
||||
// Find the default template for the specified type
|
||||
let defaultTemplate = null;
|
||||
let fallbackTemplate = null;
|
||||
|
||||
for (const setting of result.rows) {
|
||||
try {
|
||||
console.log(setting.value, typeof setting.value)
|
||||
const templateData = JSON.parse(setting.value);
|
||||
|
||||
if (templateData.type === type) {
|
||||
if (templateData.isDefault) {
|
||||
defaultTemplate = {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
break; // Found the default template
|
||||
} else if (!fallbackTemplate) {
|
||||
// Keep a fallback template in case no default is found
|
||||
fallbackTemplate = {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Return default template if found, otherwise return fallback or null
|
||||
return defaultTemplate || fallbackTemplate || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting template by type:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace template variables with actual values
|
||||
* @param {string} content - Template content
|
||||
* @param {Object} variables - Variable values
|
||||
* @returns {string} Processed content
|
||||
*/
|
||||
replaceVariables(content, variables) {
|
||||
let processedContent = content;
|
||||
|
||||
if (variables) {
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
const placeholder = `{{${key}}}`;
|
||||
const regex = new RegExp(placeholder, 'g');
|
||||
processedContent = processedContent.replace(regex, value || '');
|
||||
}
|
||||
}
|
||||
|
||||
return processedContent;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send an email using a template
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.templateType - Template type
|
||||
* @param {Object} options.variables - Template variables
|
||||
* @param {string} [options.from] - Sender email (optional, defaults to config)
|
||||
* @param {string} [options.subject] - Custom subject (optional, defaults to template subject)
|
||||
* @param {string} [options.cc] - CC recipients (optional)
|
||||
* @param {string} [options.bcc] - BCC recipients (optional)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async sendTemplatedEmail(options) {
|
||||
try {
|
||||
const { to, templateType, variables, from, subject, cc, bcc } = options;
|
||||
|
||||
// Get template
|
||||
const template = await this.getTemplateByType(templateType);
|
||||
|
||||
if (!template) {
|
||||
throw new Error(`No template found for type: ${templateType}`);
|
||||
}
|
||||
|
||||
// Replace variables in content and subject
|
||||
const emailContent = this.replaceVariables(template.content, variables);
|
||||
const emailSubject = subject || this.replaceVariables(template.subject, variables);
|
||||
|
||||
// Create transporter
|
||||
const transporter = this.createTransporter();
|
||||
|
||||
// Send email
|
||||
const result = await transporter.sendMail({
|
||||
from: from || config.email.reply,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: emailSubject,
|
||||
html: emailContent
|
||||
});
|
||||
|
||||
console.log(`Email sent: ${result.messageId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error sending templated email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a login code email
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.code - Login verification code
|
||||
* @param {string} options.loginLink - Direct login link
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async sendLoginCodeEmail(options) {
|
||||
const { to, code, loginLink } = options;
|
||||
|
||||
return this.sendTemplatedEmail({
|
||||
to,
|
||||
templateType: 'login_code',
|
||||
variables: {
|
||||
code,
|
||||
loginLink,
|
||||
email: to
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a shipping notification email
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.first_name - Customer's first name
|
||||
* @param {string} options.order_id - Order ID
|
||||
* @param {string} options.tracking_number - Tracking number
|
||||
* @param {string} options.carrier - Shipping carrier
|
||||
* @param {string} options.tracking_link - Tracking link
|
||||
* @param {string} options.shipped_date - Ship date
|
||||
* @param {string} options.estimated_delivery - Estimated delivery
|
||||
* @param {string} options.items_html - Order items HTML table
|
||||
* @param {string} options.customer_message - Custom message
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async sendShippingNotification(options) {
|
||||
return this.sendTemplatedEmail({
|
||||
to: options.to,
|
||||
templateType: 'shipping_notification',
|
||||
variables: options
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send an order confirmation email
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.first_name - Customer's first name
|
||||
* @param {string} options.order_id - Order ID
|
||||
* @param {string} options.order_date - Order date
|
||||
* @param {string} options.order_total - Order total
|
||||
* @param {string} options.shipping_address - Shipping address
|
||||
* @param {string} options.items_html - Order items HTML table
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async sendOrderConfirmation(options) {
|
||||
return this.sendTemplatedEmail({
|
||||
to: options.to,
|
||||
templateType: 'order_confirmation',
|
||||
variables: options
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a low stock alert email
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.product_name - Product name
|
||||
* @param {string} options.current_stock - Current stock level
|
||||
* @param {string} options.threshold - Stock threshold
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async sendLowStockAlert(options) {
|
||||
return this.sendTemplatedEmail({
|
||||
to: options.to,
|
||||
templateType: 'low_stock_alert',
|
||||
variables: options
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a welcome email
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.to - Recipient email
|
||||
* @param {string} options.first_name - User's first name
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async sendWelcomeEmail(options) {
|
||||
return this.sendTemplatedEmail({
|
||||
to: options.to,
|
||||
templateType: 'welcome_email',
|
||||
variables: {
|
||||
first_name: options.first_name,
|
||||
email: options.to
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Log an email in the database
|
||||
* @param {Object} emailData - Email data to log
|
||||
* @param {string} emailData.recipient - Recipient email
|
||||
* @param {string} emailData.subject - Email subject
|
||||
* @param {string} emailData.sent_by - User ID who sent the email
|
||||
* @param {string} [emailData.template_id] - Template ID used
|
||||
* @param {string} [emailData.template_type] - Template type used
|
||||
* @returns {Promise<Object>} Log entry
|
||||
*/
|
||||
async logEmail(emailData) {
|
||||
try {
|
||||
const result = await query(
|
||||
`INSERT INTO email_logs
|
||||
(recipient, subject, sent_by, template_id, template_type, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
emailData.recipient,
|
||||
emailData.subject,
|
||||
emailData.sent_by,
|
||||
emailData.template_id || null,
|
||||
emailData.template_type || null,
|
||||
'sent'
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
console.error('Error logging email:', error);
|
||||
// Don't throw error, just log it
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = emailService;
|
||||
|
|
@ -1,27 +1,10 @@
|
|||
// Create a new file: src/services/notificationService.js
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../config');
|
||||
const emailService = require('./emailService');
|
||||
|
||||
/**
|
||||
* Service for handling notifications including stock alerts
|
||||
*/
|
||||
const notificationService = {
|
||||
/**
|
||||
* Create email transporter
|
||||
* @returns {Object} Configured nodemailer transporter
|
||||
*/
|
||||
createTransporter() {
|
||||
return nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.pass
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Process pending low stock notifications
|
||||
* @param {Object} pool - Database connection pool
|
||||
|
|
@ -77,21 +60,18 @@ const notificationService = {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// Initialize email transporter
|
||||
const transporter = this.createTransporter();
|
||||
|
||||
// Send notifications for each low stock product
|
||||
for (const product of lowStockProducts.rows) {
|
||||
console.log("LOW STOCK ON: ", JSON.stringify(product, null, 4))
|
||||
const notification = product.stock_notification;
|
||||
|
||||
try {
|
||||
// Send email notification
|
||||
await transporter.sendMail({
|
||||
from: config.email.reply,
|
||||
// Send email notification using template
|
||||
await emailService.sendLowStockAlert({
|
||||
to: notification.email,
|
||||
subject: `Low Stock Alert: ${product.name}`,
|
||||
html: this.generateLowStockEmailTemplate(product)
|
||||
product_name: product.name,
|
||||
current_stock: product.stock_quantity.toString(),
|
||||
threshold: notification.threshold.toString()
|
||||
});
|
||||
|
||||
// Mark one notification as processed
|
||||
|
|
@ -129,46 +109,6 @@ const notificationService = {
|
|||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate email template for low stock notification
|
||||
* @param {Object} product - Product with low stock
|
||||
* @returns {string} HTML email template
|
||||
*/
|
||||
generateLowStockEmailTemplate(product) {
|
||||
const stockNotification = product.stock_notification;
|
||||
const threshold = stockNotification.threshold || 0;
|
||||
|
||||
return `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background-color: #f8f8f8; padding: 20px; text-align: center;">
|
||||
<h1 style="color: #ff6b6b;">Low Stock Alert</h1>
|
||||
</div>
|
||||
|
||||
<div style="padding: 20px;">
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>This is an automated notification to inform you that the following product is running low on stock:</p>
|
||||
|
||||
<div style="background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;">
|
||||
<h3 style="margin-top: 0;">${product.name}</h3>
|
||||
<p><strong>Current Stock:</strong> ${product.stock_quantity}</p>
|
||||
<p><strong>Threshold:</strong> ${threshold}</p>
|
||||
</div>
|
||||
|
||||
<p>You might want to restock this item soon to avoid running out of inventory.</p>
|
||||
|
||||
<div style="margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;">
|
||||
<p>This is an automated notification. You received this because you set up stock notifications for this product.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;">
|
||||
<p>© ${new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ VALUES
|
|||
('smtp_from_name', NULL, 'email'),
|
||||
|
||||
-- Site Settings
|
||||
('site_name', NULL, 'site'),
|
||||
-- ('site_name', NULL, 'site'),
|
||||
('site_domain', NULL, 'site'),
|
||||
('site_api_domain', NULL, 'site'),
|
||||
('site_protocol', NULL, 'site'),
|
||||
|
|
|
|||
65
db/init/15-coupon.sql
Normal file
65
db/init/15-coupon.sql
Normal 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();
|
||||
84
db/init/16-blog-schema.sql
Normal file
84
db/init/16-blog-schema.sql
Normal 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');
|
||||
80
db/init/17-product-reviews.sql
Normal file
80
db/init/17-product-reviews.sql
Normal 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();
|
||||
53
db/init/18-email-templates.sql
Normal file
53
db/init/18-email-templates.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- Add email_templates category in system_settings if needed
|
||||
INSERT INTO system_settings (key, value, category) VALUES
|
||||
('email_templates_enabled', 'true', 'email')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Add email_logs table for template logs if it doesn't exist
|
||||
ALTER TABLE email_logs ADD COLUMN IF NOT EXISTS template_id VARCHAR(255);
|
||||
ALTER TABLE email_logs ADD COLUMN IF NOT EXISTS template_type VARCHAR(50);
|
||||
|
||||
-- Create default login code template
|
||||
INSERT INTO system_settings (key, value, category)
|
||||
VALUES (
|
||||
'email_template_login_code_default',
|
||||
'{"name":"Login Code Template","type":"login_code","subject":"Your Login Code","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><h1>Your login code is: {{code}}</h1><p>This code will expire in 15 minutes.</p><p>Or click <a href=\"{{loginLink}}\">here</a> to log in directly.</p></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
|
||||
'email_templates'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Create default shipping notification template
|
||||
INSERT INTO system_settings (key, value, category)
|
||||
VALUES (
|
||||
'email_template_shipping_notification_default',
|
||||
'{"name":"Shipping Notification Template","type":"shipping_notification","subject":"Your Order Has Shipped!","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Your Order Has Shipped!</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Good news! Your order has been shipped and is on its way to you.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #4caf50;\"><h3 style=\"margin-top: 0;\">Shipping Details</h3><p><strong>Carrier:</strong> {{carrier}}</p><p><strong>Tracking Number:</strong> <a href=\"{{tracking_link}}\" target=\"_blank\">{{tracking_number}}</a></p><p><strong>Shipped On:</strong> {{shipped_date}}</p><p><strong>Estimated Delivery:</strong> {{estimated_delivery}}</p></div><div style=\"margin-top: 30px;\"><h3>Order Summary</h3><table style=\"width: 100%; border-collapse: collapse;\"><thead><tr style=\"background-color: #f2f2f2;\"><th style=\"padding: 10px; text-align: left;\">Item</th><th style=\"padding: 10px; text-align: left;\">Qty</th><th style=\"padding: 10px; text-align: left;\">Price</th><th style=\"padding: 10px; text-align: left;\">Total</th></tr></thead><tbody>{{items_html}}</tbody></table></div><div style=\"margin-top: 30px; border-top: 1px solid #eee; padding-top: 20px;\"><p>Thank you for your purchase!</p></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>© 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
|
||||
'email_templates'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Create default order confirmation template
|
||||
INSERT INTO system_settings (key, value, category)
|
||||
VALUES (
|
||||
'email_template_order_confirmation_default',
|
||||
'{"name":"Order Confirmation Template","type":"order_confirmation","subject":"Order Confirmation","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Order Confirmation</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Thank you for your order! We are processing it now and will send you another email when it ships.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0;\"><h3 style=\"margin-top: 0;\">Order Details</h3><p><strong>Order Date:</strong> {{order_date}}</p><p><strong>Order Total:</strong> {{order_total}}</p><p><strong>Shipping To:</strong> {{shipping_address}}</p></div><div style=\"margin-top: 30px;\"><h3>Order Summary</h3><table style=\"width: 100%; border-collapse: collapse;\"><thead><tr style=\"background-color: #f2f2f2;\"><th style=\"padding: 10px; text-align: left;\">Item</th><th style=\"padding: 10px; text-align: left;\">Qty</th><th style=\"padding: 10px; text-align: left;\">Price</th><th style=\"padding: 10px; text-align: left;\">Total</th></tr></thead><tbody>{{items_html}}</tbody></table></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>© 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
|
||||
'email_templates'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Create default low stock alert template
|
||||
INSERT INTO system_settings (key, value, category)
|
||||
VALUES (
|
||||
'email_template_low_stock_alert_default',
|
||||
'{"name":"Low Stock Alert Template","type":"low_stock_alert","subject":"Low Stock Alert: {{product_name}}","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #ff6b6b;\">Low Stock Alert</h1></div><div style=\"padding: 20px;\"><p>Hello,</p><p>This is an automated notification to inform you that the following product is running low on stock:</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0; border-left: 4px solid #ff6b6b;\"><h3 style=\"margin-top: 0;\">{{product_name}}</h3><p><strong>Current Stock:</strong> {{current_stock}}</p><p><strong>Threshold:</strong> {{threshold}}</p></div><p>You might want to restock this item soon to avoid running out of inventory.</p></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>© 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
|
||||
'email_templates'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Create default welcome email template
|
||||
INSERT INTO system_settings (key, value, category)
|
||||
VALUES (
|
||||
'email_template_welcome_email_default',
|
||||
'{"name":"Welcome Email Template","type":"welcome_email","subject":"Welcome to Rocks, Bones & Sticks!","content":"<div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;\"><div style=\"background-color: #f8f8f8; padding: 20px; text-align: center;\"><h1 style=\"color: #333;\">Welcome to Rocks, Bones & Sticks!</h1></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>Thank you for creating an account with us. We are excited to have you join our community of natural curiosity enthusiasts!</p><p>As a member, you will enjoy:</p><ul><li>Access to our unique collection of natural specimens</li><li>Special offers and promotions</li><li>Early access to new items</li></ul><p>Start exploring our collections today and discover the beauty of nature!</p><div style=\"margin-top: 30px; text-align: center;\"><a href=\"#\" style=\"background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px;\">Shop Now</a></div></div><div style=\"background-color: #333; color: white; padding: 15px; text-align: center; font-size: 12px;\"><p>© 2025 Rocks, Bones & Sticks. All rights reserved.</p></div></div>","isDefault":true,"createdAt":"2025-04-29T00:00:00.000Z"}',
|
||||
'email_templates'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
49
db/init/19-branding-settings.sql
Normal file
49
db/init/19-branding-settings.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- Add branding category to system_settings if needed
|
||||
INSERT INTO system_settings (key, value, category) VALUES
|
||||
('branding_enabled', 'true', 'branding')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Create default branding settings
|
||||
INSERT INTO system_settings (key, value, category)
|
||||
VALUES
|
||||
('site_name', 'Rocks, Bones & Sticks', 'branding'),
|
||||
|
||||
|
||||
('site_main_page_title', 'Discover Natural Wonders', 'branding'),
|
||||
('site_main_page_subtitle', 'Unique rocks, bones, and sticks from around my backyard', 'branding'),
|
||||
('site_main_newsletter_desc', 'Subscribe to our newsletter for updates on new items and promotions', 'branding'),
|
||||
('site_main_bottom_sting', 'Ready to explore more?', 'branding'),
|
||||
('site_description', 'Your premier source for natural curiosities and unique specimens', 'branding'),
|
||||
('site_quicklinks_title', 'Quick Links', 'branding'),
|
||||
('site_connect', 'Connect With Us', 'branding'),
|
||||
|
||||
('blog_title', 'Our Blog', 'branding'),
|
||||
('blog_desc', 'Discover insights about our natural collections, sourcing adventures, and unique specimens', 'branding'),
|
||||
('blog_no_content_title', 'No blog posts found', 'branding'),
|
||||
('blog_no_content_subtitle', 'Check back soon for new content', 'branding'),
|
||||
('blog_search', 'Search blog posts', 'branding'),
|
||||
|
||||
('cart_empty', 'Your Cart is Empty', 'branding'),
|
||||
('cart_empty_subtitle', 'Looks like you have not added any items to your cart yet.', 'branding'),
|
||||
|
||||
('product_title', 'Products', 'branding'),
|
||||
|
||||
('orders_title', 'My Orders', 'branding'),
|
||||
('orders_empty', 'You have not placed any orders yet.', 'branding'),
|
||||
|
||||
|
||||
('default_mode', 'light', 'branding'),
|
||||
('copyright_text', '© 2025 Rocks, Bones & Sticks. All rights reserved.', 'branding'),
|
||||
('light_primary_color', '#7e57c2', 'branding'),
|
||||
('light_secondary_color', '#ffb300', 'branding'),
|
||||
('light_background_color', '#f5f5f5', 'branding'),
|
||||
('light_surface_color', '#ffffff', 'branding'),
|
||||
('light_text_color', '#000000', 'branding'),
|
||||
('dark_primary_color', '#9575cd', 'branding'),
|
||||
('dark_secondary_color', '#ffd54f', 'branding'),
|
||||
('dark_background_color', '#212121', 'branding'),
|
||||
('dark_surface_color', '#424242', 'branding'),
|
||||
('dark_text_color', '#ffffff', 'branding'),
|
||||
('logo_url', '', 'branding'),
|
||||
('favicon_url', '', 'branding')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
|
@ -1,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
|
||||
4365
frontend/package-lock.json
generated
4365
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -19,12 +19,12 @@
|
|||
"@stripe/react-stripe-js": "^2.4.0",
|
||||
"@stripe/stripe-js": "^2.2.0",
|
||||
"@tanstack/react-query": "^5.12.2",
|
||||
"@tanstack/react-query-devtools": "^5.12.2",
|
||||
"axios": "^1.6.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-email-editor": "^1.7.11",
|
||||
"react-redux": "^9.0.2",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"recharts": "^2.10.3"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { Routes, Route } from 'react-router-dom';
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Suspense, lazy, useEffect } from 'react';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
import Notifications from './components/Notifications';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Notifications from '@components/Notifications';
|
||||
import ProtectedRoute from '@components/ProtectedRoute';
|
||||
import { StripeProvider } from './context/StripeContext';
|
||||
import useBrandingSettings from '@hooks/brandingHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
// Layouts
|
||||
// Import layouts
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import AuthLayout from './layouts/AuthLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
138
frontend/src/components/CouponInput.jsx
Normal file
138
frontend/src/components/CouponInput.jsx
Normal 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;
|
||||
|
|
@ -4,8 +4,15 @@ import FacebookIcon from '@mui/icons-material/Facebook';
|
|||
import TwitterIcon from '@mui/icons-material/Twitter';
|
||||
import InstagramIcon from '@mui/icons-material/Instagram';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const Footer = () => {
|
||||
const Footer = ({brandingSettings}) => {
|
||||
|
||||
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
|
||||
const copyrightText = brandingSettings?.copyright_text ||
|
||||
`© ${new Date().getFullYear()} ${siteName}. All rights reserved.`;
|
||||
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
|
|
@ -22,18 +29,33 @@ const Footer = () => {
|
|||
<Container maxWidth="lg">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
Rocks, Bones & Sticks
|
||||
</Typography>
|
||||
{logoUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoUrl}
|
||||
alt={siteName}
|
||||
sx={{
|
||||
height: 40,
|
||||
maxWidth: '100%',
|
||||
mb: 2,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
{siteName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Your premier source for natural curiosities
|
||||
and unique specimens from my backyards.
|
||||
{ brandingSettings?.site_description || `Your premier source for natural curiosities
|
||||
and unique specimens from around the world.`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
Quick Links
|
||||
{brandingSettings?.site_quicklinks_title || `Quick Links`}
|
||||
</Typography>
|
||||
<Link component={RouterLink} to="/" color="inherit" display="block">
|
||||
Home
|
||||
|
|
@ -41,11 +63,14 @@ const Footer = () => {
|
|||
<Link component={RouterLink} to="/products" color="inherit" display="block">
|
||||
Shop All
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/blog" color="inherit" display="block">
|
||||
Blog
|
||||
</Link>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Typography variant="h6" color="text.primary" gutterBottom>
|
||||
Connect With Us
|
||||
{brandingSettings?.site_connect || `Connect With Us`}
|
||||
</Typography>
|
||||
<Box>
|
||||
<IconButton aria-label="facebook" color="primary">
|
||||
|
|
@ -59,14 +84,14 @@ const Footer = () => {
|
|||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Subscribe to our newsletter for updates on new items and promotions.
|
||||
{brandingSettings?.site_main_newsletter_desc || `Subscribe to our newsletter for updates on new items and promotions.`}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box mt={3}>
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
© {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
|
||||
{copyrightText}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ import {
|
|||
Card,
|
||||
CardMedia,
|
||||
CardActions,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
|
|
@ -28,13 +26,15 @@ import imageUtils from '@utils/imageUtils';
|
|||
* @param {Function} props.onChange - Callback when images change
|
||||
* @param {boolean} props.multiple - Whether to allow multiple images
|
||||
* @param {boolean} props.admin - Whether this is for admin use
|
||||
* @param {string} props.inputId - Unique ID for the file input element
|
||||
* @returns {JSX.Element} - Image uploader component
|
||||
*/
|
||||
const ImageUploader = ({
|
||||
images = [],
|
||||
onChange,
|
||||
multiple = true,
|
||||
admin = true
|
||||
admin = true,
|
||||
inputId = `image-upload-input-${Math.random().toString(36).substring(2, 9)}`
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
|
@ -129,18 +129,18 @@ const ImageUploader = ({
|
|||
|
||||
return (
|
||||
<Box>
|
||||
{/* Hidden file input */}
|
||||
{/* Hidden file input with unique ID */}
|
||||
<input
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
id="image-upload-input"
|
||||
id={inputId}
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
|
||||
{/* Upload button */}
|
||||
<label htmlFor="image-upload-input">
|
||||
{/* Upload button with matching htmlFor */}
|
||||
<label htmlFor={inputId}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="span"
|
||||
|
|
@ -185,7 +185,7 @@ const ImageUploader = ({
|
|||
component="img"
|
||||
sx={{ height: 140, objectFit: 'cover' }}
|
||||
image={imageUtils.getImageUrl(image.path)}
|
||||
alt={`Product image ${index + 1}`}
|
||||
alt={`Image ${index + 1}`}
|
||||
/>
|
||||
<CardActions sx={{ justifyContent: 'space-between', mt: 'auto' }}>
|
||||
<Tooltip title={image.isPrimary ? "Primary Image" : "Set as Primary"}>
|
||||
|
|
|
|||
27
frontend/src/components/ProductRatingDisplay.jsx
Normal file
27
frontend/src/components/ProductRatingDisplay.jsx
Normal 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;
|
||||
328
frontend/src/components/ProductReviews.jsx
Normal file
328
frontend/src/components/ProductReviews.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
235
frontend/src/hooks/blogHooks.js
Normal file
235
frontend/src/hooks/blogHooks.js
Normal 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'
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
38
frontend/src/hooks/brandingHooks.js
Normal file
38
frontend/src/hooks/brandingHooks.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '@services/api';
|
||||
|
||||
/**
|
||||
* Custom hook for accessing branding settings
|
||||
* Uses React Query's caching to prevent multiple redundant API calls
|
||||
*
|
||||
* @returns {Object} Query result with branding settings
|
||||
*/
|
||||
export const useBrandingSettings = () => {
|
||||
return useQuery({
|
||||
queryKey: ['branding-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/settings/branding');
|
||||
|
||||
// Convert the array of settings into an object for easier access
|
||||
const settings = {};
|
||||
if (Array.isArray(response.data)) {
|
||||
response.data.forEach(setting => {
|
||||
settings[setting.key] = setting.value;
|
||||
});
|
||||
} else {
|
||||
// If response is already an object, use it directly
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error('Error fetching branding settings:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
||||
});
|
||||
};
|
||||
|
||||
export default useBrandingSettings;
|
||||
117
frontend/src/hooks/couponAdminHooks.js
Normal file
117
frontend/src/hooks/couponAdminHooks.js
Normal 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
|
||||
};
|
||||
146
frontend/src/hooks/emailTemplateHooks.js
Normal file
146
frontend/src/hooks/emailTemplateHooks.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import emailTemplateService from '@services/emailTemplateService';
|
||||
import { useNotification } from './reduxHooks';
|
||||
|
||||
/**
|
||||
* Custom hooks for email template management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook for fetching all email templates
|
||||
*/
|
||||
export const useEmailTemplates = () => {
|
||||
return useQuery({
|
||||
queryKey: ['email-templates'],
|
||||
queryFn: emailTemplateService.getAllTemplates
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching templates by type
|
||||
* @param {string} type - Template type
|
||||
*/
|
||||
export const useEmailTemplatesByType = (type) => {
|
||||
return useQuery({
|
||||
queryKey: ['email-templates', type],
|
||||
queryFn: () => emailTemplateService.getTemplatesByType(type),
|
||||
enabled: !!type
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching a single template by ID
|
||||
* @param {string} id - Template ID
|
||||
*/
|
||||
export const useEmailTemplate = (id) => {
|
||||
return useQuery({
|
||||
queryKey: ['email-template', id],
|
||||
queryFn: () => emailTemplateService.getTemplateById(id),
|
||||
enabled: !!id
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for fetching the default template for a type
|
||||
* @param {string} type - Template type
|
||||
*/
|
||||
export const useDefaultEmailTemplate = (type) => {
|
||||
return useQuery({
|
||||
queryKey: ['default-email-template', type],
|
||||
queryFn: () => emailTemplateService.getDefaultTemplate(type),
|
||||
enabled: !!type
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for creating a new email template
|
||||
*/
|
||||
export const useCreateEmailTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (templateData) => emailTemplateService.createTemplate(templateData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates', data.type] });
|
||||
notification.showNotification('Email template created successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to create email template',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for updating an email template
|
||||
*/
|
||||
export const useUpdateEmailTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, templateData }) => emailTemplateService.updateTemplate(id, templateData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates', data.type] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-template', data.id] });
|
||||
notification.showNotification('Email template updated successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to update email template',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for deleting an email template
|
||||
*/
|
||||
export const useDeleteEmailTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id) => emailTemplateService.deleteTemplate(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
notification.showNotification('Email template deleted successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to delete email template',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for setting a template as default
|
||||
*/
|
||||
export const useSetDefaultEmailTemplate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const notification = useNotification();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id) => emailTemplateService.setAsDefault(id),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates', data.type] });
|
||||
queryClient.invalidateQueries({ queryKey: ['default-email-template', data.type] });
|
||||
notification.showNotification('Default template set successfully', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
notification.showNotification(
|
||||
error.message || 'Failed to set default template',
|
||||
'error'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
114
frontend/src/hooks/productReviewHooks.js
Normal file
114
frontend/src/hooks/productReviewHooks.js
Normal 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'
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,19 @@ import { Outlet } from 'react-router-dom';
|
|||
import { Box, Container, Paper, Typography, Button } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import useBrandingSettings from '@hooks/brandingHooks';
|
||||
import imageUtils from '@utils/imageUtils';
|
||||
|
||||
const AuthLayout = () => {
|
||||
// Use the centralized hook to fetch branding settings
|
||||
const { data: brandingSettings } = useBrandingSettings();
|
||||
|
||||
// Get site name and logo from branding settings
|
||||
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
|
||||
const logoUrl = imageUtils.getImageUrl(brandingSettings?.logo_url)
|
||||
const copyrightText = brandingSettings?.copyright_text ||
|
||||
`© ${new Date().getFullYear()} ${siteName}. All rights reserved.`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -34,9 +45,23 @@ const AuthLayout = () => {
|
|||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h4" gutterBottom>
|
||||
Rocks, Bones & Sticks
|
||||
</Typography>
|
||||
{logoUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoUrl}
|
||||
alt={siteName}
|
||||
sx={{
|
||||
height: 60,
|
||||
maxWidth: '100%',
|
||||
mb: 3,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography component="h1" variant="h4" gutterBottom>
|
||||
{siteName}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ width: '100%', mt: 2 }}>
|
||||
<Outlet />
|
||||
|
|
@ -58,7 +83,7 @@ const AuthLayout = () => {
|
|||
>
|
||||
<Container maxWidth="sm" sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
© {new Date().getFullYear()} Rocks, Bones & Sticks. All rights reserved.
|
||||
{copyrightText}
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
333
frontend/src/pages/Admin/BlogCommentsPage.jsx
Normal file
333
frontend/src/pages/Admin/BlogCommentsPage.jsx
Normal 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;
|
||||
476
frontend/src/pages/Admin/BlogEditPage.jsx
Normal file
476
frontend/src/pages/Admin/BlogEditPage.jsx
Normal 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;
|
||||
342
frontend/src/pages/Admin/BlogPage.jsx
Normal file
342
frontend/src/pages/Admin/BlogPage.jsx
Normal 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;
|
||||
945
frontend/src/pages/Admin/BrandingPage.jsx
Normal file
945
frontend/src/pages/Admin/BrandingPage.jsx
Normal file
|
|
@ -0,0 +1,945 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
TextField,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Divider,
|
||||
Tabs,
|
||||
Tab,
|
||||
Card,
|
||||
CardContent,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Tooltip,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Delete as DeleteIcon,
|
||||
ColorLens as ColorLensIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useAdminSettingsByCategory, useUpdateSetting, useUpdateSettings } from '../../hooks/settingsAdminHooks';
|
||||
import ImageUploader from '@components/ImageUploader';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// Default theme colors to reset to
|
||||
const DEFAULT_COLORS = {
|
||||
light: {
|
||||
primary: '#7e57c2', // deepPurple[400]
|
||||
secondary: '#ffb300', // amber[500]
|
||||
background: '#f5f5f5',
|
||||
surface: '#ffffff',
|
||||
text: '#000000',
|
||||
},
|
||||
dark: {
|
||||
primary: '#9575cd', // deepPurple[300]
|
||||
secondary: '#ffd54f', // amber[300]
|
||||
background: '#212121',
|
||||
surface: '#424242',
|
||||
text: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
function ColorPickerInput({ label, value, onChange, defaultValue }) {
|
||||
const handleChange = (e) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
onChange(defaultValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
|
||||
<Tooltip title="Reset to default">
|
||||
<IconButton onClick={handleReset} size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
type="color"
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Box
|
||||
sx={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
bgcolor: value,
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd'
|
||||
}}
|
||||
/>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const BrandingPage = () => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [logoImage, setLogoImage] = useState([]);
|
||||
const [faviconImage, setFaviconImage] = useState([]);
|
||||
const [notification, setNotification] = useState({ open: false, message: '', severity: 'success' });
|
||||
|
||||
// Get the React Query client for cache invalidation
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: brandingSettings, isLoading: settingsLoading, error: settingsError, refetch } = useAdminSettingsByCategory('branding');
|
||||
const [formData, setFormData] = useState({
|
||||
site_name: '',
|
||||
site_main_page_title: '',
|
||||
site_main_page_subtitle: '',
|
||||
site_main_newsletter_desc: '',
|
||||
site_main_bottom_sting: '',
|
||||
site_quicklinks_title: '',
|
||||
site_connect: '',
|
||||
blog_title: '',
|
||||
blog_desc: '',
|
||||
blog_no_content_title: '',
|
||||
blog_no_content_subtitle: '',
|
||||
blog_search: '',
|
||||
cart_empty: '',
|
||||
cart_empty_subtitle: '',
|
||||
product_title: '',
|
||||
orders_title: '',
|
||||
orders_empty: '',
|
||||
site_description: '',
|
||||
default_mode: 'light',
|
||||
light_primary_color: DEFAULT_COLORS.light.primary,
|
||||
light_secondary_color: DEFAULT_COLORS.light.secondary,
|
||||
light_background_color: DEFAULT_COLORS.light.background,
|
||||
light_surface_color: DEFAULT_COLORS.light.surface,
|
||||
light_text_color: DEFAULT_COLORS.light.text,
|
||||
dark_primary_color: DEFAULT_COLORS.dark.primary,
|
||||
dark_secondary_color: DEFAULT_COLORS.dark.secondary,
|
||||
dark_background_color: DEFAULT_COLORS.dark.background,
|
||||
dark_surface_color: DEFAULT_COLORS.dark.surface,
|
||||
dark_text_color: DEFAULT_COLORS.dark.text,
|
||||
logo_url: '',
|
||||
favicon_url: '',
|
||||
copyright_text: '',
|
||||
});
|
||||
// Update settings mutation
|
||||
const updateSettings = useUpdateSettings();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (event, newValue) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
// Handle notification close
|
||||
const handleCloseNotification = () => {
|
||||
setNotification({ ...notification, open: false });
|
||||
};
|
||||
|
||||
// Initialize form data when settings are loaded
|
||||
useEffect(() => {
|
||||
if (brandingSettings) {
|
||||
const initialData = {};
|
||||
brandingSettings?.forEach(setting => {
|
||||
initialData[setting.key] = setting.value;
|
||||
});
|
||||
|
||||
// Apply settings to form
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
...initialData
|
||||
}));
|
||||
|
||||
if (initialData.logo_url) {
|
||||
setLogoImage([{ path: initialData.logo_url, isPrimary: true }]);
|
||||
} else {
|
||||
setLogoImage([]);
|
||||
}
|
||||
|
||||
if (initialData.favicon_url) {
|
||||
setFaviconImage([{ path: initialData.favicon_url, isPrimary: true }]);
|
||||
} else {
|
||||
setFaviconImage([]);
|
||||
}
|
||||
}
|
||||
}, [brandingSettings]);
|
||||
|
||||
// Handle form input changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value, checked, type } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked.toString() : value
|
||||
});
|
||||
};
|
||||
|
||||
// Handle color picker changes
|
||||
const handleColorChange = (name, value) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
// Handle logo image changes
|
||||
const handleLogoChange = (images) => {
|
||||
setLogoImage(images);
|
||||
if (images && images.length > 0) {
|
||||
setFormData({
|
||||
...formData,
|
||||
logo_url: images[0].path
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
logo_url: ''
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle favicon image changes
|
||||
const handleFaviconChange = (images) => {
|
||||
setFaviconImage(images);
|
||||
if (images && images.length > 0) {
|
||||
setFormData({
|
||||
...formData,
|
||||
favicon_url: images[0].path
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
favicon_url: ''
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Reset to default colors
|
||||
const handleResetColors = (mode) => {
|
||||
if (mode === 'light') {
|
||||
setFormData({
|
||||
...formData,
|
||||
light_primary_color: DEFAULT_COLORS.light.primary,
|
||||
light_secondary_color: DEFAULT_COLORS.light.secondary,
|
||||
light_background_color: DEFAULT_COLORS.light.background,
|
||||
light_surface_color: DEFAULT_COLORS.light.surface,
|
||||
light_text_color: DEFAULT_COLORS.light.text,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
dark_primary_color: DEFAULT_COLORS.dark.primary,
|
||||
dark_secondary_color: DEFAULT_COLORS.dark.secondary,
|
||||
dark_background_color: DEFAULT_COLORS.dark.background,
|
||||
dark_surface_color: DEFAULT_COLORS.dark.surface,
|
||||
dark_text_color: DEFAULT_COLORS.dark.text,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Save all settings
|
||||
const handleSaveSettings = async () => {
|
||||
const settingsToUpdate = [];
|
||||
|
||||
// Convert form data to settings array
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
settingsToUpdate.push({
|
||||
key,
|
||||
value,
|
||||
category: 'branding'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (settingsToUpdate.length > 0) {
|
||||
try {
|
||||
await updateSettings.mutateAsync(settingsToUpdate);
|
||||
|
||||
// Show success notification
|
||||
setNotification({
|
||||
open: true,
|
||||
message: 'Branding settings saved successfully',
|
||||
severity: 'success'
|
||||
});
|
||||
|
||||
// Invalidate the branding settings query to trigger a refresh in all components
|
||||
queryClient.invalidateQueries(['branding-settings']);
|
||||
|
||||
// Refresh to get updated settings in this component
|
||||
refetch();
|
||||
|
||||
// Update the favicon immediately if it was changed
|
||||
if (formData.favicon_url) {
|
||||
const link = document.querySelector("link[rel*='icon']") || document.createElement('link');
|
||||
link.type = 'image/x-icon';
|
||||
link.rel = 'shortcut icon';
|
||||
link.href = formData.favicon_url;
|
||||
document.getElementsByTagName('head')[0].appendChild(link);
|
||||
}
|
||||
|
||||
// Update document title if site name changed
|
||||
if (formData.site_name) {
|
||||
document.title = formData.site_name;
|
||||
}
|
||||
} catch (error) {
|
||||
// Show error notification
|
||||
setNotification({
|
||||
open: true,
|
||||
message: `Failed to save settings: ${error.message}`,
|
||||
severity: 'error'
|
||||
});
|
||||
console.error('Failed to update settings:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (settingsError) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ my: 2 }}>
|
||||
Error loading branding settings: {settingsError.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Branding & Theme Settings
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ mb: 4 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
aria-label="branding tabs"
|
||||
>
|
||||
<Tab label="General" id="branding-tab-0" />
|
||||
<Tab label="Colors & Theme" id="branding-tab-1" />
|
||||
<Tab label="Assets" id="branding-tab-2" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* General Settings Tab */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
General Settings
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site Name"
|
||||
name="site_name"
|
||||
value={formData.site_name || ''}
|
||||
onChange={handleChange}
|
||||
helperText="The name of your site (e.g., Rocks, Bones & Sticks)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.default_mode === 'dark'}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
default_mode: e.target.checked ? 'dark' : 'light'
|
||||
})}
|
||||
name="default_mode"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Use Dark Mode by Default"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site Description"
|
||||
name="site_description"
|
||||
multiline
|
||||
rows={2}
|
||||
value={formData.site_description || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Short description for SEO and social media"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Copyright Text"
|
||||
name="copyright_text"
|
||||
value={formData.copyright_text || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text to display in the footer (e.g., © 2025 Your Company Name. All rights reserved.)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Main Banner Title"
|
||||
name="site_main_page_title"
|
||||
value={formData.site_main_page_title || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text to display in the Banner Title (e.g., Discover Natural Wonders)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Main Banner Subtitle"
|
||||
name="site_main_page_subtitle"
|
||||
value={formData.site_main_page_subtitle || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text to display in the Banner Subtitle (e.g., Unique rocks, bones, and sticks from around the world)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Newletter Description"
|
||||
name="site_main_newsletter_desc"
|
||||
value={formData.site_main_newsletter_desc || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text to display in the Footer (e.g., Subscribe to our newsletter for updates on new items and promotions)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Banner Bottom"
|
||||
name="site_main_bottom_sting"
|
||||
value={formData.site_main_bottom_sting || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text to display in the Bottom Banner (e.g., Ready to explore more?)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site Description"
|
||||
name="site_description"
|
||||
value={formData.site_description || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text For the Site description (e.g., Your premier source for natural curiosities and unique specimens)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Quick Links Title"
|
||||
name="site_quicklinks_title"
|
||||
value={formData.site_quicklinks_title || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text For Quick links (e.g., Quick Links)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Connect us Title"
|
||||
name="site_connect"
|
||||
value={formData.site_connect || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text For Connect with us section(e.g., Connect with us )"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Blog Title"
|
||||
name="blog_title"
|
||||
value={formData.blog_title || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text For Blog Title(e.g., Our Story )"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Blog Description"
|
||||
name="blog_desc"
|
||||
value={formData.blog_desc || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text For Blog Description (e.g., Discover insights about our natural collections, sourcing adventures, and unique specimens )"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Blog No Content Title"
|
||||
name="blog_no_content_title"
|
||||
value={formData.blog_no_content_title || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text For no found Blog content (e.g., No blog posts found )"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Blog No Content Subtitle"
|
||||
name="blog_no_content_subtitle"
|
||||
value={formData.blog_no_content_subtitle || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Subtitle For no found Blog content (e.g., Check back soon for new content)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Blog Search Text"
|
||||
name="blog_search"
|
||||
value={formData.blog_search || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Search text Blog Page (e.g., Search blog posts)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Cart Empty Text"
|
||||
name="cart_empty"
|
||||
value={formData.cart_empty || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text for empty cart (e.g., Your Cart is Empty)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Cart Empty Subtitle"
|
||||
name="cart_empty_subtitle"
|
||||
value={formData.cart_empty_subtitle || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Main Text for empty cart (e.g., Looks like you have not added any items to your cart yet.)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Product Page Title"
|
||||
name="product_title"
|
||||
value={formData.product_title || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text for Produce page title (e.g., Products)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Orders Page Title"
|
||||
name="orders_title"
|
||||
value={formData.orders_title || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text for Orders page title (e.g., My Orders)"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Orders No Content"
|
||||
name="orders_empty"
|
||||
value={formData.orders_empty || ''}
|
||||
onChange={handleChange}
|
||||
helperText="Text for Orders page no content (e.g., You have not placed any orders yet.)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Colors & Theme Tab */}
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<Grid container spacing={4}>
|
||||
{/* Light Mode Colors */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Light Mode Colors</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => handleResetColors('light')}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Primary Color"
|
||||
value={formData.light_primary_color || DEFAULT_COLORS.light.primary}
|
||||
onChange={(value) => handleColorChange('light_primary_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.light.primary}
|
||||
/>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Secondary Color"
|
||||
value={formData.light_secondary_color || DEFAULT_COLORS.light.secondary}
|
||||
onChange={(value) => handleColorChange('light_secondary_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.light.secondary}
|
||||
/>
|
||||
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Advanced Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<ColorPickerInput
|
||||
label="Background Color"
|
||||
value={formData.light_background_color || DEFAULT_COLORS.light.background}
|
||||
onChange={(value) => handleColorChange('light_background_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.light.background}
|
||||
/>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Surface Color"
|
||||
value={formData.light_surface_color || DEFAULT_COLORS.light.surface}
|
||||
onChange={(value) => handleColorChange('light_surface_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.light.surface}
|
||||
/>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Text Color"
|
||||
value={formData.light_text_color || DEFAULT_COLORS.light.text}
|
||||
onChange={(value) => handleColorChange('light_text_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.light.text}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Box sx={{ bgcolor: formData.light_background_color, p: 3, borderRadius: 1, border: '1px solid #ddd' }}>
|
||||
<Typography variant="h6" sx={{ color: formData.light_text_color }}>Preview</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 2,
|
||||
gap: 2
|
||||
}}>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: formData.light_primary_color,
|
||||
'&:hover': {
|
||||
bgcolor: formData.light_primary_color,
|
||||
filter: 'brightness(0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Primary
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: formData.light_secondary_color,
|
||||
'&:hover': {
|
||||
bgcolor: formData.light_secondary_color,
|
||||
filter: 'brightness(0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Secondary
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Dark Mode Colors */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Dark Mode Colors</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => handleResetColors('dark')}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Primary Color"
|
||||
value={formData.dark_primary_color || DEFAULT_COLORS.dark.primary}
|
||||
onChange={(value) => handleColorChange('dark_primary_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.dark.primary}
|
||||
/>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Secondary Color"
|
||||
value={formData.dark_secondary_color || DEFAULT_COLORS.dark.secondary}
|
||||
onChange={(value) => handleColorChange('dark_secondary_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.dark.secondary}
|
||||
/>
|
||||
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Advanced Colors</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<ColorPickerInput
|
||||
label="Background Color"
|
||||
value={formData.dark_background_color || DEFAULT_COLORS.dark.background}
|
||||
onChange={(value) => handleColorChange('dark_background_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.dark.background}
|
||||
/>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Surface Color"
|
||||
value={formData.dark_surface_color || DEFAULT_COLORS.dark.surface}
|
||||
onChange={(value) => handleColorChange('dark_surface_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.dark.surface}
|
||||
/>
|
||||
|
||||
<ColorPickerInput
|
||||
label="Text Color"
|
||||
value={formData.dark_text_color || DEFAULT_COLORS.dark.text}
|
||||
onChange={(value) => handleColorChange('dark_text_color', value)}
|
||||
defaultValue={DEFAULT_COLORS.dark.text}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
<Box sx={{ bgcolor: formData.dark_background_color, p: 3, borderRadius: 1, border: '1px solid #333' }}>
|
||||
<Typography variant="h6" sx={{ color: formData.dark_text_color }}>Preview</Typography>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
mt: 2,
|
||||
gap: 2
|
||||
}}>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: formData.dark_primary_color,
|
||||
'&:hover': {
|
||||
bgcolor: formData.dark_primary_color,
|
||||
filter: 'brightness(0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Primary
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
bgcolor: formData.dark_secondary_color,
|
||||
'&:hover': {
|
||||
bgcolor: formData.dark_secondary_color,
|
||||
filter: 'brightness(0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Secondary
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Assets Tab */}
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Grid container spacing={3}>
|
||||
|
||||
{/* Favicon Upload */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Favicon
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Upload a favicon for your site. This will appear in browser tabs. Recommended size: 32px by 32px.
|
||||
</Typography>
|
||||
|
||||
<ImageUploader
|
||||
images={faviconImage}
|
||||
onChange={handleFaviconChange}
|
||||
multiple={false}
|
||||
/>
|
||||
|
||||
{faviconImage.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Current Favicon
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={faviconImage[0].path}
|
||||
alt="Current favicon"
|
||||
sx={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
|
||||
{/* Logo Upload */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Logo
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Upload your site logo. Recommended size: 200px by 50px.
|
||||
</Typography>
|
||||
|
||||
<ImageUploader
|
||||
images={logoImage}
|
||||
onChange={handleLogoChange}
|
||||
multiple={false}
|
||||
/>
|
||||
|
||||
{logoImage.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Current Logo
|
||||
</Typography>
|
||||
<Box
|
||||
component="img"
|
||||
src={logoImage[0].path}
|
||||
alt="Current logo"
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
maxHeight: '100px',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', p: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={refetch}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSaveSettings}
|
||||
disabled={updateSettings.isLoading}
|
||||
>
|
||||
{updateSettings.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Notification */}
|
||||
<Snackbar
|
||||
open={notification.open}
|
||||
autoHideDuration={6000}
|
||||
onClose={handleCloseNotification}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<Alert
|
||||
onClose={handleCloseNotification}
|
||||
severity={notification.severity}
|
||||
sx={{ width: '100%' }}
|
||||
>
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// TabPanel component
|
||||
function TabPanel(props) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`branding-tabpanel-${index}`}
|
||||
aria-labelledby={`branding-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrandingPage;
|
||||
380
frontend/src/pages/Admin/CouponsPage.jsx
Normal file
380
frontend/src/pages/Admin/CouponsPage.jsx
Normal 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;
|
||||
660
frontend/src/pages/Admin/EmailTemplatesPage.jsx
Normal file
660
frontend/src/pages/Admin/EmailTemplatesPage.jsx
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
TextField,
|
||||
Button,
|
||||
Grid,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Card,
|
||||
CardContent
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Save as SaveIcon,
|
||||
Visibility as PreviewIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
FormatBold as BoldIcon,
|
||||
FormatItalic as FormatItalicIcon,
|
||||
FormatListBulleted as BulletListIcon,
|
||||
FormatListNumbered as NumberedListIcon,
|
||||
Link as LinkIcon,
|
||||
Title as TitleIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
import EmailEditor from 'react-email-editor';
|
||||
import { useAdminSettingsByCategory, useDeleteSetting, useUpdateSetting } from '../../hooks/settingsAdminHooks';
|
||||
|
||||
// Available email template types
|
||||
const EMAIL_TYPES = [
|
||||
{ id: 'login_code', name: 'Login Code', description: 'Sent when a user requests a login code' },
|
||||
{ id: 'shipping_notification', name: 'Shipping Notification', description: 'Sent when an order is shipped' },
|
||||
{ id: 'order_confirmation', name: 'Order Confirmation', description: 'Sent when an order is placed' },
|
||||
{ id: 'low_stock_alert', name: 'Low Stock Alert', description: 'Sent when product stock falls below threshold' },
|
||||
{ id: 'welcome_email', name: 'Welcome Email', description: 'Sent when a user registers for the first time' },
|
||||
{ id: 'custom', name: 'Custom Template', description: 'A custom email template for any purpose' }
|
||||
];
|
||||
|
||||
// Template variable placeholders for each email type
|
||||
const TEMPLATE_VARIABLES = {
|
||||
login_code: [
|
||||
{ key: '{{code}}', description: 'The login verification code' },
|
||||
{ key: '{{loginLink}}', description: 'Direct login link with the code' },
|
||||
{ key: '{{email}}', description: 'User\'s email address' }
|
||||
],
|
||||
shipping_notification: [
|
||||
{ key: '{{first_name}}', description: 'Customer\'s first name' },
|
||||
{ key: '{{order_id}}', description: 'Order identifier' },
|
||||
{ key: '{{tracking_number}}', description: 'Shipping tracking number' },
|
||||
{ key: '{{carrier}}', description: 'Shipping carrier name' },
|
||||
{ key: '{{tracking_link}}', description: 'Link to track the package' },
|
||||
{ key: '{{shipped_date}}', description: 'Date the order was shipped' },
|
||||
{ key: '{{estimated_delivery}}', description: 'Estimated delivery date/time' },
|
||||
{ key: '{{items_html}}', description: 'HTML table of ordered items' },
|
||||
{ key: '{{customer_message}}', description: 'Optional message from staff' }
|
||||
],
|
||||
order_confirmation: [
|
||||
{ key: '{{first_name}}', description: 'Customer\'s first name' },
|
||||
{ key: '{{order_id}}', description: 'Order identifier' },
|
||||
{ key: '{{order_date}}', description: 'Date the order was placed' },
|
||||
{ key: '{{order_total}}', description: 'Total amount of the order' },
|
||||
{ key: '{{shipping_address}}', description: 'Shipping address' },
|
||||
{ key: '{{items_html}}', description: 'HTML table of ordered items' }
|
||||
],
|
||||
low_stock_alert: [
|
||||
{ key: '{{product_name}}', description: 'Name of the product low in stock' },
|
||||
{ key: '{{current_stock}}', description: 'Current stock quantity' },
|
||||
{ key: '{{threshold}}', description: 'Low stock threshold' }
|
||||
],
|
||||
welcome_email: [
|
||||
{ key: '{{first_name}}', description: 'User\'s first name' },
|
||||
{ key: '{{email}}', description: 'User\'s email address' }
|
||||
],
|
||||
custom: [] // Custom templates might have any variables
|
||||
};
|
||||
|
||||
// Sample placeholder data for preview
|
||||
const PREVIEW_DATA = {
|
||||
login_code: {
|
||||
code: '123456',
|
||||
loginLink: 'https://example.com/verify?code=123456&email=user@example.com',
|
||||
email: 'user@example.com'
|
||||
},
|
||||
shipping_notification: {
|
||||
first_name: 'Jane',
|
||||
order_id: 'ORD-1234567',
|
||||
tracking_number: 'TRK123456789',
|
||||
carrier: 'FedEx',
|
||||
tracking_link: 'https://www.fedex.com/track?123456789',
|
||||
shipped_date: '2025-04-29',
|
||||
estimated_delivery: '2-3 business days',
|
||||
items_html: `
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">Amethyst Geode</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">1</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$49.99</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$49.99</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">Driftwood Piece</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">2</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$14.99</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$29.98</td>
|
||||
</tr>
|
||||
`,
|
||||
customer_message: 'Thank you for your order! We packaged it with extra care.'
|
||||
},
|
||||
order_confirmation: {
|
||||
first_name: 'John',
|
||||
order_id: 'ORD-9876543',
|
||||
order_date: '2025-04-29',
|
||||
order_total: '$94.97',
|
||||
shipping_address: '123 Main St, Anytown, CA 12345',
|
||||
items_html: `
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">Polished Labradorite</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">1</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$29.99</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$29.99</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">Fossil Fish</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">1</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$64.98</td>
|
||||
<td style="padding: 10px; border-bottom: 1px solid #eee;">$64.98</td>
|
||||
</tr>
|
||||
`
|
||||
},
|
||||
low_stock_alert: {
|
||||
product_name: 'Amethyst Geode',
|
||||
current_stock: '2',
|
||||
threshold: '5'
|
||||
},
|
||||
welcome_email: {
|
||||
first_name: 'Emily',
|
||||
email: 'emily@example.com'
|
||||
},
|
||||
custom: {}
|
||||
};
|
||||
|
||||
// Default templates
|
||||
const DEFAULT_TEMPLATES = {
|
||||
login_code: {
|
||||
// Simplified template structure for React Email Editor
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
values: {
|
||||
containerPadding: "10px",
|
||||
textAlign: "left",
|
||||
text: "<h1>Your login code is: {{code}}</h1><p>This code will expire in 15 minutes.</p><p>Or click <a href=\"{{loginLink}}\">here</a> to log in directly.</p>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
shipping_notification: {
|
||||
// Simplified template - the actual structure would be more complex in the real editor
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
values: {
|
||||
containerPadding: "10px",
|
||||
textAlign: "center",
|
||||
text: "<h1>Your Order Has Shipped!</h1><p>Order #{{order_id}}</p>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
values: {
|
||||
containerPadding: "10px",
|
||||
textAlign: "left",
|
||||
text: "<p>Hello {{first_name}},</p><p>Good news! Your order has been shipped and is on its way to you.</p>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
welcome_email: {
|
||||
// Simplified template
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
values: {
|
||||
containerPadding: "10px",
|
||||
textAlign: "center",
|
||||
text: "<h1>Welcome to Rocks, Bones & Sticks!</h1>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
values: {
|
||||
containerPadding: "10px",
|
||||
textAlign: "left",
|
||||
text: "<p>Hello {{first_name}},</p><p>Thank you for creating an account with us. We're excited to have you join our community!</p>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const EmailTemplatesPage = () => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [editingTemplate, setEditingTemplate] = useState(null);
|
||||
const [templateList, setTemplateList] = useState([]);
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState('');
|
||||
|
||||
const emailEditorRef = useRef(null);
|
||||
|
||||
const { data: emailSettings, isLoading, error } = useAdminSettingsByCategory('email_templates');
|
||||
const deleteSettingMutation = useDeleteSetting();
|
||||
const updateSetting = useUpdateSetting();
|
||||
|
||||
useEffect(() => {
|
||||
if (emailSettings) {
|
||||
const templates = emailSettings.map(setting => {
|
||||
try {
|
||||
const templateData = JSON.parse(setting.value);
|
||||
return { id: setting.key, ...templateData };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
setTemplateList(templates);
|
||||
}
|
||||
}, [emailSettings]);
|
||||
|
||||
const handleTabChange = (e, newValue) => {
|
||||
// Only allow tab switching if not currently editing a template
|
||||
if (!editingTemplate) {
|
||||
setActiveTab(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template) => {
|
||||
setEditingTemplate({ ...template });
|
||||
|
||||
// If the email editor is loaded, set its design
|
||||
if (emailEditorRef.current && template.design) {
|
||||
setTimeout(() => {
|
||||
emailEditorRef.current.editor.loadDesign(template.design);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async () => {
|
||||
if (!editingTemplate || !emailEditorRef.current) return;
|
||||
|
||||
try {
|
||||
// Save the design from the email editor
|
||||
emailEditorRef.current.editor.exportHtml(async (data) => {
|
||||
const { design, html } = data;
|
||||
|
||||
// Update the template with the new design and HTML
|
||||
const updatedTemplate = {
|
||||
...editingTemplate,
|
||||
design: design, // Store the design JSON for future editing
|
||||
content: html, // Store the generated HTML for rendering
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
await updateSetting.mutateAsync({
|
||||
key: updatedTemplate.id,
|
||||
value: JSON.stringify(updatedTemplate),
|
||||
category: 'email_templates'
|
||||
});
|
||||
|
||||
setTemplateList(prev => prev.map(t => t.id === updatedTemplate.id ? updatedTemplate : t));
|
||||
setEditingTemplate(null);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = (template) => {
|
||||
if (template.content) {
|
||||
setPreviewContent(`
|
||||
<div style="max-width:600px;margin:0 auto;border:1px solid #ccc">
|
||||
<div style="padding:10px;background:#f5f5f5;font-weight:bold">Subject: ${template.subject}</div>
|
||||
${template.content}
|
||||
</div>
|
||||
`);
|
||||
setPreviewDialogOpen(true);
|
||||
} else if (emailEditorRef.current) {
|
||||
emailEditorRef.current.editor.exportHtml((data) => {
|
||||
const { html } = data;
|
||||
setPreviewContent(`
|
||||
<div style="max-width:600px;margin:0 auto;border:1px solid #ccc">
|
||||
<div style="padding:10px;background:#f5f5f5;font-weight:bold">Subject: ${template.subject}</div>
|
||||
${html}
|
||||
</div>
|
||||
`);
|
||||
setPreviewDialogOpen(true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTemplate = () => {
|
||||
const templateType = EMAIL_TYPES[activeTab === 0 ? 5 : activeTab - 1].id;
|
||||
const templateName = EMAIL_TYPES[activeTab === 0 ? 5 : activeTab - 1].name;
|
||||
|
||||
// Create default HTML content for the new template
|
||||
let defaultContent = `<h1>Your ${templateName}</h1><p>Start editing this template to customize it for your needs.</p>`;
|
||||
|
||||
// Add sample placeholders based on template type
|
||||
if (templateType === 'login_code') {
|
||||
defaultContent = `<h1>Your login code is: {{code}}</h1>
|
||||
<p>This code will expire in 15 minutes.</p>
|
||||
<p>Or click <a href="{{loginLink}}">here</a> to log in directly.</p>`;
|
||||
} else if (templateType === 'shipping_notification') {
|
||||
defaultContent = `<h1>Your Order Has Shipped!</h1>
|
||||
<p>Hello {{first_name}},</p>
|
||||
<p>Good news! Your order #{{order_id}} has been shipped and is on its way to you.</p>`;
|
||||
} else if (templateType === 'welcome_email') {
|
||||
defaultContent = `<h1>Welcome to Rocks, Bones & Sticks!</h1>
|
||||
<p>Hello {{first_name}},</p>
|
||||
<p>Thank you for creating an account with us. We're excited to have you join our community!</p>`;
|
||||
}
|
||||
|
||||
const newTemplate = {
|
||||
id: `email_template_${Date.now()}`,
|
||||
name: `New ${templateName}`,
|
||||
type: templateType,
|
||||
subject: `Your ${templateName}`,
|
||||
content: defaultContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
setEditingTemplate(newTemplate);
|
||||
};
|
||||
|
||||
const onEditorReady = () => {
|
||||
// You can perform any setup actions here when the editor is loaded
|
||||
console.log('Email editor is ready');
|
||||
|
||||
// If there's a template being edited, load its design
|
||||
if (editingTemplate?.design && emailEditorRef.current) {
|
||||
emailEditorRef.current.editor.loadDesign(editingTemplate.design);
|
||||
}
|
||||
// If there's no design but we have HTML content, create a default design with that content
|
||||
else if (editingTemplate?.content && emailEditorRef.current) {
|
||||
const defaultDesign = {
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "html",
|
||||
values: {
|
||||
html: editingTemplate.content,
|
||||
containerPadding: "10px"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
emailEditorRef.current.editor.loadDesign(defaultDesign);
|
||||
}
|
||||
// If it's a new template with no design or content, load the default template
|
||||
else if (editingTemplate && emailEditorRef.current) {
|
||||
// Try to load a default template for the template type
|
||||
const defaultTemplate = DEFAULT_TEMPLATES[editingTemplate.type] || {
|
||||
body: {
|
||||
rows: [
|
||||
{
|
||||
cells: [1],
|
||||
columns: [
|
||||
{
|
||||
contents: [
|
||||
{
|
||||
type: "html",
|
||||
values: {
|
||||
html: "<h1>Your " + editingTemplate.name + "</h1><p>Start editing your email template here.</p>",
|
||||
containerPadding: "10px"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
emailEditorRef.current.editor.loadDesign(defaultTemplate);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Box textAlign="center" py={5}><CircularProgress /></Box>;
|
||||
if (error) return <Alert severity="error">Error loading templates.</Alert>;
|
||||
let first_name = "{{first_name}}"
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" mb={2}>Email Templates</Typography>
|
||||
<Paper sx={{ mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 2 }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
disabled={!!editingTemplate}
|
||||
>
|
||||
<Tab label="All" />
|
||||
{EMAIL_TYPES.map(type => <Tab key={type.id} label={type.name} />)}
|
||||
</Tabs>
|
||||
{activeTab > 0 && (
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
color="primary"
|
||||
variant="contained"
|
||||
onClick={handleCreateTemplate}
|
||||
sx={{ my: 1 }}
|
||||
disabled={!!editingTemplate}
|
||||
>
|
||||
Create {EMAIL_TYPES[activeTab - 1]?.name} Template
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{!editingTemplate ? (
|
||||
<Grid container spacing={2}>
|
||||
{templateList.length > 0 ? (
|
||||
templateList
|
||||
.filter(template => activeTab === 0 || template.type === EMAIL_TYPES[activeTab - 1]?.id)
|
||||
.map(template => (
|
||||
<Grid item xs={12} md={6} key={template.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="h6">{template.name}</Typography>
|
||||
<Chip
|
||||
label={EMAIL_TYPES.find(t => t.id === template.type)?.name || 'Unknown'}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" display="block" color="text.secondary" gutterBottom>
|
||||
Last updated: {new Date(template.updatedAt).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Subject: {template.subject}
|
||||
</Typography>
|
||||
<Box mt={2} display="flex" gap={1}>
|
||||
<Button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
startIcon={<EditIcon />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handlePreviewTemplate(template)}
|
||||
startIcon={<PreviewIcon />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))
|
||||
) : (
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
No email templates found. Click "Create Template" to create your first template.
|
||||
</Alert>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
) : (
|
||||
// Edit view
|
||||
<Box mb={3}>
|
||||
<Paper sx={{ p: 2, mb: 2 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Template Name"
|
||||
value={editingTemplate?.name || ''}
|
||||
onChange={(e) => setEditingTemplate(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Subject Line"
|
||||
value={editingTemplate?.subject || ''}
|
||||
onChange={(e) => setEditingTemplate(prev => ({ ...prev, subject: e.target.value }))}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom sx={{ mr: 1 }}>Email Content</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight="medium">Tips for creating effective email templates:</Typography>
|
||||
<ol>
|
||||
<li>For best results, design your emails visually in Figma first</li>
|
||||
<li>Export your design as HTML or use an email-specific design tool</li>
|
||||
<li>Copy the HTML into an HTML block in this editor</li>
|
||||
<li>Add dynamic variables like {`{{first_name}}`} as text where needed</li>
|
||||
<li>Reference "Available Template Variables" below this tip</li>
|
||||
</ol>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
|
||||
<Accordion sx={{ mb: 2 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>Available Template Variables</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
You can use these variables in your message. They will be replaced when the email is sent.
|
||||
</Typography>
|
||||
<List dense>
|
||||
{TEMPLATE_VARIABLES[editingTemplate.type]?.map(variable => (
|
||||
<ListItem key={variable.key}>
|
||||
<ListItemText
|
||||
primary={variable.key}
|
||||
secondary={variable.description}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
|
||||
<Box sx={{ mb: 2, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button onClick={() => setEditingTemplate(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => handlePreviewTemplate(editingTemplate)}
|
||||
startIcon={<PreviewIcon />}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTemplate}
|
||||
startIcon={<SaveIcon />}
|
||||
variant="contained"
|
||||
disabled={updateSetting.isLoading}
|
||||
>
|
||||
{updateSetting.isLoading ? <CircularProgress size={24} /> : 'Save Changes'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1, height: '600px' }}>
|
||||
<EmailEditor
|
||||
ref={emailEditorRef}
|
||||
onReady={onEditorReady}
|
||||
minHeight="600px"
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Preview Dialog */}
|
||||
<Dialog open={previewDialogOpen} onClose={() => setPreviewDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Email Preview</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box dangerouslySetInnerHTML={{ __html: previewContent }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPreviewDialogOpen(false)}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplatesPage;
|
||||
|
|
@ -47,6 +47,8 @@ import apiClient from '@services/api';
|
|||
import { format } from 'date-fns';
|
||||
import ProductImage from '@components/ProductImage';
|
||||
import OrderStatusDialog from '@components/OrderStatusDialog';
|
||||
import useBrandingSettings from '@hooks/brandingHooks';
|
||||
const { data: brandingSettings } = useBrandingSettings();
|
||||
|
||||
const AdminOrdersPage = () => {
|
||||
const [page, setPage] = useState(0);
|
||||
|
|
@ -56,6 +58,7 @@ const AdminOrdersPage = () => {
|
|||
const [selectedOrder, setSelectedOrder] = useState(null);
|
||||
const [orderDetails, setOrderDetails] = useState(null);
|
||||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||||
const { data: brandingSettings } = useBrandingSettings();
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
|
|
|||
366
frontend/src/pages/Admin/ProductReviewsPage.jsx
Normal file
366
frontend/src/pages/Admin/ProductReviewsPage.jsx
Normal 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;
|
||||
361
frontend/src/pages/BlogDetailPage.jsx
Normal file
361
frontend/src/pages/BlogDetailPage.jsx
Normal 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;
|
||||
312
frontend/src/pages/BlogPage.jsx
Normal file
312
frontend/src/pages/BlogPage.jsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
596
frontend/src/pages/CouponEditPage.jsx
Normal file
596
frontend/src/pages/CouponEditPage.jsx
Normal 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;
|
||||
215
frontend/src/pages/CouponRedemptionsPage.jsx
Normal file
215
frontend/src/pages/CouponRedemptionsPage.jsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
160
frontend/src/services/blogAdminService.js
Normal file
160
frontend/src/services/blogAdminService.js
Normal 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;
|
||||
62
frontend/src/services/blogService.js
Normal file
62
frontend/src/services/blogService.js
Normal 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;
|
||||
133
frontend/src/services/couponService.js
Normal file
133
frontend/src/services/couponService.js
Normal 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;
|
||||
203
frontend/src/services/emailTemplateService.js
Normal file
203
frontend/src/services/emailTemplateService.js
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import apiClient from './api';
|
||||
|
||||
/**
|
||||
* Service for managing email templates
|
||||
*/
|
||||
const emailTemplateService = {
|
||||
/**
|
||||
* Get all email templates
|
||||
* @returns {Promise<Array>} Array of email templates
|
||||
*/
|
||||
getAllTemplates: async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/settings/category/email_templates');
|
||||
|
||||
// Transform settings into template objects
|
||||
return response.data.map(setting => {
|
||||
try {
|
||||
// Parse the template data from the JSON value
|
||||
const templateData = JSON.parse(setting.value);
|
||||
return {
|
||||
id: setting.key,
|
||||
...templateData
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to parse template setting: ${setting.key}`, e);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean); // Remove any null entries
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to fetch email templates' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific email template by ID
|
||||
* @param {string} id - Template ID
|
||||
* @returns {Promise<Object>} Email template object
|
||||
*/
|
||||
getTemplateById: async (id) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/admin/settings/${id}`);
|
||||
|
||||
// Parse the template data from the JSON value
|
||||
const templateData = JSON.parse(response.data.value);
|
||||
return {
|
||||
id: response.data.key,
|
||||
...templateData
|
||||
};
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to fetch email template' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get templates by type
|
||||
* @param {string} type - Template type (e.g., 'login_code', 'shipping_notification')
|
||||
* @returns {Promise<Array>} Array of email templates of the specified type
|
||||
*/
|
||||
getTemplatesByType: async (type) => {
|
||||
try {
|
||||
const templates = await emailTemplateService.getAllTemplates();
|
||||
return templates.filter(template => template.type === type);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the default template for a specific type
|
||||
* @param {string} type - Template type
|
||||
* @returns {Promise<Object|null>} Default email template for the type, or null if none exists
|
||||
*/
|
||||
getDefaultTemplate: async (type) => {
|
||||
try {
|
||||
const templates = await emailTemplateService.getTemplatesByType(type);
|
||||
return templates.find(template => template.isDefault) || null;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new email template
|
||||
* @param {Object} templateData - Template data
|
||||
* @param {string} templateData.name - Template name
|
||||
* @param {string} templateData.type - Template type
|
||||
* @param {string} templateData.subject - Email subject line
|
||||
* @param {string} templateData.content - HTML content of the email template
|
||||
* @returns {Promise<Object>} Created email template
|
||||
*/
|
||||
createTemplate: async (templateData) => {
|
||||
try {
|
||||
// Generate a unique key for the setting
|
||||
const templateKey = `email_template_${Date.now()}`;
|
||||
|
||||
// Create the template object
|
||||
const template = {
|
||||
name: templateData.name,
|
||||
type: templateData.type,
|
||||
subject: templateData.subject,
|
||||
content: templateData.content,
|
||||
isDefault: templateData.isDefault || false,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Save to settings
|
||||
const response = await apiClient.put(`/admin/settings/${templateKey}`, {
|
||||
value: JSON.stringify(template),
|
||||
category: 'email_templates'
|
||||
});
|
||||
|
||||
return {
|
||||
id: templateKey,
|
||||
...template
|
||||
};
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to create email template' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing email template
|
||||
* @param {string} id - Template ID
|
||||
* @param {Object} templateData - Updated template data
|
||||
* @returns {Promise<Object>} Updated email template
|
||||
*/
|
||||
updateTemplate: async (id, templateData) => {
|
||||
try {
|
||||
// Create the updated template object
|
||||
const template = {
|
||||
name: templateData.name,
|
||||
type: templateData.type,
|
||||
subject: templateData.subject,
|
||||
content: templateData.content,
|
||||
isDefault: templateData.isDefault || false,
|
||||
createdAt: templateData.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Update in settings
|
||||
await apiClient.put(`/admin/settings/${id}`, {
|
||||
value: JSON.stringify(template),
|
||||
category: 'email_templates'
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
...template
|
||||
};
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to update email template' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an email template
|
||||
* @param {string} id - Template ID
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
deleteTemplate: async (id) => {
|
||||
try {
|
||||
await apiClient.delete(`/admin/settings/${id}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to delete email template' };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a template as the default for its type
|
||||
* @param {string} id - Template ID
|
||||
* @returns {Promise<Object>} Updated template
|
||||
*/
|
||||
setAsDefault: async (id) => {
|
||||
try {
|
||||
// Get the template to set as default
|
||||
const template = await emailTemplateService.getTemplateById(id);
|
||||
|
||||
// Get all templates of the same type
|
||||
const typeTemplates = await emailTemplateService.getTemplatesByType(template.type);
|
||||
|
||||
// For each template of the same type, unset default if it's set
|
||||
for (const t of typeTemplates) {
|
||||
if (t.id !== id && t.isDefault) {
|
||||
await emailTemplateService.updateTemplate(t.id, {
|
||||
...t,
|
||||
isDefault: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set the selected template as default
|
||||
return await emailTemplateService.updateTemplate(id, {
|
||||
...template,
|
||||
isDefault: true
|
||||
});
|
||||
} catch (error) {
|
||||
throw error.response?.data || { message: 'Failed to set template as default' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default emailTemplateService;
|
||||
112
frontend/src/services/productReviewService.js
Normal file
112
frontend/src/services/productReviewService.js
Normal 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;
|
||||
|
|
@ -1,15 +1,191 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { useAppTheme } from './index';
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { red, amber, grey, deepPurple } from '@mui/material/colors';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '@services/api';
|
||||
import { useDarkMode } from '@hooks/reduxHooks';
|
||||
import useBrandingSettings from '@hooks/brandingHooks';
|
||||
|
||||
/**
|
||||
* Custom ThemeProvider that uses the app's theme with dark mode support
|
||||
* This component should be used instead of the direct MUI ThemeProvider
|
||||
* Custom ThemeProvider that uses branding settings for white labeling
|
||||
*/
|
||||
const ThemeProvider = ({ children }) => {
|
||||
const theme = useAppTheme();
|
||||
|
||||
const [darkMode, _, setDarkMode] = useDarkMode();
|
||||
const [theme, setTheme] = useState(null);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: brandingSettings } = useBrandingSettings();
|
||||
|
||||
// Default colors
|
||||
const defaultColors = {
|
||||
light: {
|
||||
primary: deepPurple[400],
|
||||
secondary: amber[500],
|
||||
error: red.A400,
|
||||
background: '#f5f5f5',
|
||||
paper: '#fff',
|
||||
text: '#000000',
|
||||
},
|
||||
dark: {
|
||||
primary: deepPurple[300],
|
||||
secondary: amber[300],
|
||||
error: red.A400,
|
||||
background: grey[900],
|
||||
paper: grey[800],
|
||||
text: '#ffffff',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Only pull from default if user specified style hasn't been set
|
||||
if(brandingSettings){
|
||||
let isDarkMode = darkMode;
|
||||
if(localStorage.getItem("darkMode") == null){
|
||||
isDarkMode = brandingSettings?.default_mode === 'dark' ? true : darkMode;
|
||||
setDarkMode(isDarkMode);
|
||||
}
|
||||
handleDarkMode(isDarkMode)
|
||||
}
|
||||
}, [brandingSettings, darkMode]);
|
||||
function handleDarkMode(darkMode){
|
||||
let isDarkMode = darkMode;
|
||||
|
||||
// Get colors based on mode
|
||||
const mode = isDarkMode ? 'dark' : 'light';
|
||||
const colors = {
|
||||
primary: isDarkMode
|
||||
? (brandingSettings?.dark_primary_color || defaultColors.dark.primary)
|
||||
: (brandingSettings?.light_primary_color || defaultColors.light.primary),
|
||||
secondary: isDarkMode
|
||||
? (brandingSettings?.dark_secondary_color || defaultColors.dark.secondary)
|
||||
: (brandingSettings?.light_secondary_color || defaultColors.light.secondary),
|
||||
background: isDarkMode
|
||||
? (brandingSettings?.dark_background_color || defaultColors.dark.background)
|
||||
: (brandingSettings?.light_background_color || defaultColors.light.background),
|
||||
paper: isDarkMode
|
||||
? (brandingSettings?.dark_surface_color || defaultColors.dark.paper)
|
||||
: (brandingSettings?.light_surface_color || defaultColors.light.paper),
|
||||
text: isDarkMode
|
||||
? (brandingSettings?.dark_text_color || defaultColors.dark.text)
|
||||
: (brandingSettings?.light_text_color || defaultColors.light.text)
|
||||
};
|
||||
|
||||
// Create theme
|
||||
const newTheme = createTheme({
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
main: colors.primary,
|
||||
},
|
||||
secondary: {
|
||||
main: colors.secondary,
|
||||
},
|
||||
error: {
|
||||
main: defaultColors[mode].error,
|
||||
},
|
||||
background: {
|
||||
default: colors.background,
|
||||
paper: colors.paper,
|
||||
},
|
||||
text: {
|
||||
primary: colors.text,
|
||||
secondary: isDarkMode
|
||||
? 'rgba(255, 255, 255, 0.7)'
|
||||
: 'rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'Roboto',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
h1: {
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 6,
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
boxShadow: isDarkMode
|
||||
? '0 4px 20px rgba(0, 0, 0, 0.5)'
|
||||
: '0 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: isDarkMode
|
||||
? '0 4px 20px rgba(0, 0, 0, 0.5)'
|
||||
: '0 2px 10px rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setTheme(newTheme);
|
||||
}
|
||||
// Use a default theme while loading
|
||||
if (!theme) {
|
||||
const defaultTheme = createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: darkMode ? defaultColors.dark.primary : defaultColors.light.primary,
|
||||
},
|
||||
secondary: {
|
||||
main: darkMode ? defaultColors.dark.secondary : defaultColors.light.secondary,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={defaultTheme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
|
|
|||
Loading…
Reference in a new issue