fixed image place holder, supported refunds, removed temp images
This commit is contained in:
parent
91b4c2de76
commit
0fe6a195ed
22 changed files with 663 additions and 99 deletions
|
|
@ -105,12 +105,11 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update order status (simple version)
|
// Update order status with refund process
|
||||||
router.patch('/:id', async (req, res, next) => {
|
router.patch('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { status } = req.body;
|
const { status, refundReason } = req.body;
|
||||||
|
|
||||||
|
|
||||||
if (!req.user.is_admin) {
|
if (!req.user.is_admin) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
|
|
@ -128,25 +127,308 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update order status
|
// Get order with customer information before updating
|
||||||
const result = await query(`
|
const orderResult = await query(`
|
||||||
UPDATE orders
|
SELECT o.*, o.payment_id, o.total_amount, u.email, u.first_name, u.last_name
|
||||||
SET status = $1, updated_at = NOW()
|
FROM orders o
|
||||||
WHERE id = $2
|
JOIN users u ON o.user_id = u.id
|
||||||
RETURNING *
|
WHERE o.id = $1
|
||||||
`, [status, id]);
|
`, [id]);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (orderResult.rows.length === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: true,
|
error: true,
|
||||||
message: 'Order not found'
|
message: 'Order not found'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const order = orderResult.rows[0];
|
||||||
|
|
||||||
|
// Begin transaction
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Special handling for refunded status
|
||||||
|
if (status === 'refunded' && order.status !== 'refunded') {
|
||||||
|
// Check if payment_id exists
|
||||||
|
if (!order.payment_id) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Cannot refund order without payment information'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process the refund with Stripe
|
||||||
|
const stripe = require('stripe')(config.payment.stripeSecretKey);
|
||||||
|
|
||||||
|
// If the payment ID is a Stripe Checkout session ID, get the associated payment intent
|
||||||
|
let paymentIntentId = order.payment_id;
|
||||||
|
|
||||||
|
// If it starts with 'cs_', it's a checkout session
|
||||||
|
if (paymentIntentId.startsWith('cs_')) {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(paymentIntentId);
|
||||||
|
paymentIntentId = session.payment_intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the refund
|
||||||
|
const refund = await stripe.refunds.create({
|
||||||
|
payment_intent: paymentIntentId,
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store refund information in the order
|
||||||
|
const refundInfo = {
|
||||||
|
refund_id: refund.id,
|
||||||
|
amount: refund.amount / 100, // Convert from cents to dollars
|
||||||
|
status: refund.status,
|
||||||
|
reason: refundReason || 'Customer request',
|
||||||
|
created: new Date(refund.created * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update order with refund information
|
||||||
|
await client.query(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status = $1,
|
||||||
|
updated_at = NOW(),
|
||||||
|
payment_notes = CASE
|
||||||
|
WHEN payment_notes IS NULL THEN $2::jsonb
|
||||||
|
ELSE payment_notes::jsonb || $2::jsonb
|
||||||
|
END
|
||||||
|
WHERE id = $3
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
status,
|
||||||
|
JSON.stringify({ refund: refundInfo }),
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send refund confirmation email
|
||||||
|
await emailService.sendRefundConfirmation({
|
||||||
|
to: order.email,
|
||||||
|
first_name: order.first_name || 'Customer',
|
||||||
|
order_id: order.id.substring(0, 8), // Use first 8 characters of UUID for cleaner display
|
||||||
|
refund_amount: `$${refundInfo.amount.toFixed(2)}`,
|
||||||
|
refund_date: new Date(refundInfo.created).toLocaleDateString(),
|
||||||
|
refund_method: 'Original payment method',
|
||||||
|
refund_reason: refundInfo.reason
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (stripeError) {
|
||||||
|
console.error('Stripe refund error:', stripeError);
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: `Failed to process refund: ${stripeError.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For non-refund status updates, just update the status
|
||||||
|
await client.query(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status = $1, updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
`, [status, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Get updated order
|
||||||
|
const updatedOrderResult = await query(
|
||||||
|
'SELECT * FROM orders WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Order status updated successfully',
|
message: 'Order status updated successfully',
|
||||||
order: result.rows[0]
|
order: updatedOrderResult.rows[0]
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/refund', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const {
|
||||||
|
amount,
|
||||||
|
reason,
|
||||||
|
refundItems, // Optional: array of { item_id, quantity } for partial refunds
|
||||||
|
sendEmail = true // Whether to send confirmation email
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!req.user.is_admin) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order details with customer information
|
||||||
|
const orderResult = await query(`
|
||||||
|
SELECT o.*, o.payment_id, o.total_amount, u.email, u.first_name, u.last_name
|
||||||
|
FROM orders o
|
||||||
|
JOIN users u ON o.user_id = u.id
|
||||||
|
WHERE o.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (orderResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Order not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = orderResult.rows[0];
|
||||||
|
|
||||||
|
// Check if payment_id exists
|
||||||
|
if (!order.payment_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Cannot refund order without payment information'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin transaction
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Determine if this is a full or partial refund
|
||||||
|
const refundAmount = amount || order.total_amount;
|
||||||
|
const isFullRefund = Math.abs(refundAmount - order.total_amount) < 0.01; // Account for possible floating point issues
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process the refund with Stripe
|
||||||
|
const stripe = require('stripe')(config.payment.stripeSecretKey);
|
||||||
|
|
||||||
|
// If the payment ID is a Stripe Checkout session ID, get the associated payment intent
|
||||||
|
let paymentIntentId = order.payment_id;
|
||||||
|
|
||||||
|
// If it starts with 'cs_', it's a checkout session
|
||||||
|
if (paymentIntentId.startsWith('cs_')) {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(paymentIntentId);
|
||||||
|
paymentIntentId = session.payment_intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the refund
|
||||||
|
const refund = await stripe.refunds.create({
|
||||||
|
payment_intent: paymentIntentId,
|
||||||
|
amount: Math.round(refundAmount * 100), // Convert to cents for Stripe
|
||||||
|
reason: 'requested_by_customer',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store refund information in the order
|
||||||
|
const refundInfo = {
|
||||||
|
refund_id: refund.id,
|
||||||
|
amount: refund.amount / 100, // Convert from cents to dollars
|
||||||
|
status: refund.status,
|
||||||
|
reason: reason || 'Customer request',
|
||||||
|
created: new Date(refund.created * 1000).toISOString(),
|
||||||
|
is_full_refund: isFullRefund,
|
||||||
|
refunded_items: refundItems || []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update order status and refund info
|
||||||
|
if (isFullRefund) {
|
||||||
|
// Full refund changes order status to refunded
|
||||||
|
await client.query(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status = 'refunded',
|
||||||
|
updated_at = NOW(),
|
||||||
|
payment_notes = CASE
|
||||||
|
WHEN payment_notes IS NULL THEN $1::jsonb
|
||||||
|
ELSE payment_notes::jsonb || $1::jsonb
|
||||||
|
END
|
||||||
|
WHERE id = $2
|
||||||
|
`, [
|
||||||
|
JSON.stringify({ refund: refundInfo }),
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Partial refund doesn't change order status
|
||||||
|
await client.query(`
|
||||||
|
UPDATE orders
|
||||||
|
SET updated_at = NOW(),
|
||||||
|
payment_notes = CASE
|
||||||
|
WHEN payment_notes IS NULL THEN $1::jsonb
|
||||||
|
ELSE payment_notes::jsonb || $1::jsonb
|
||||||
|
END
|
||||||
|
WHERE id = $2
|
||||||
|
`, [
|
||||||
|
JSON.stringify({ partial_refund: refundInfo }),
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If specific items were refunded, update their refund status in order_items
|
||||||
|
if (refundItems && refundItems.length > 0) {
|
||||||
|
for (const item of refundItems) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE order_items
|
||||||
|
SET refunded = true, refunded_quantity = $1
|
||||||
|
WHERE id = $2 AND order_id = $3
|
||||||
|
`, [
|
||||||
|
item.quantity,
|
||||||
|
item.item_id,
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send refund confirmation email if requested
|
||||||
|
if (sendEmail) {
|
||||||
|
await emailService.sendRefundConfirmation({
|
||||||
|
to: order.email,
|
||||||
|
first_name: order.first_name || 'Customer',
|
||||||
|
order_id: order.id.substring(0, 8), // Use first 8 characters of UUID for cleaner display
|
||||||
|
refund_amount: `$${refundInfo.amount.toFixed(2)}`,
|
||||||
|
refund_date: new Date(refundInfo.created).toLocaleDateString(),
|
||||||
|
refund_method: 'Original payment method',
|
||||||
|
refund_reason: refundInfo.reason
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Get the updated order
|
||||||
|
const updatedOrderResult = await query(
|
||||||
|
'SELECT * FROM orders WHERE id = $1',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${isFullRefund ? 'Full' : 'Partial'} refund processed successfully`,
|
||||||
|
refund: refundInfo,
|
||||||
|
order: updatedOrderResult.rows[0]
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (stripeError) {
|
||||||
|
console.error('Stripe refund error:', stripeError);
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: `Failed to process refund: ${stripeError.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,26 @@ const emailService = {
|
||||||
// Don't throw error, just log it
|
// Don't throw error, just log it
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a refund 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.refund_amount - Refund amount
|
||||||
|
* @param {string} options.refund_date - Refund date
|
||||||
|
* @param {string} options.refund_method - Refund method (e.g., "Original payment method")
|
||||||
|
* @param {string} options.refund_reason - Reason for refund
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async sendRefundConfirmation(options) {
|
||||||
|
return this.sendTemplatedEmail({
|
||||||
|
to: options.to,
|
||||||
|
templateType: 'refund_confirmation',
|
||||||
|
variables: options
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ SELECT
|
||||||
'Purple',
|
'Purple',
|
||||||
'Quartz',
|
'Quartz',
|
||||||
'Brazil',
|
'Brazil',
|
||||||
'/images/amethyst-geode.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Rock';
|
FROM categories WHERE name = 'Rock';
|
||||||
|
|
||||||
WITH categories AS (
|
WITH categories AS (
|
||||||
|
|
@ -41,7 +41,7 @@ SELECT
|
||||||
'Gray/Blue',
|
'Gray/Blue',
|
||||||
'Feldspar',
|
'Feldspar',
|
||||||
'Madagascar',
|
'Madagascar',
|
||||||
'/images/labradorite.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Rock';
|
FROM categories WHERE name = 'Rock';
|
||||||
|
|
||||||
WITH categories AS (
|
WITH categories AS (
|
||||||
|
|
@ -58,7 +58,7 @@ SELECT
|
||||||
'Turquoise',
|
'Turquoise',
|
||||||
'Turquoise',
|
'Turquoise',
|
||||||
'Arizona',
|
'Arizona',
|
||||||
'/images/turquoise.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Rock';
|
FROM categories WHERE name = 'Rock';
|
||||||
|
|
||||||
-- Insert bone products
|
-- Insert bone products
|
||||||
|
|
@ -74,7 +74,7 @@ SELECT
|
||||||
8,
|
8,
|
||||||
38.5,
|
38.5,
|
||||||
'Antler',
|
'Antler',
|
||||||
'/images/deer-antler.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Bone';
|
FROM categories WHERE name = 'Bone';
|
||||||
|
|
||||||
WITH categories AS (
|
WITH categories AS (
|
||||||
|
|
@ -89,7 +89,7 @@ SELECT
|
||||||
5,
|
5,
|
||||||
22.8,
|
22.8,
|
||||||
'Fossilized Bone',
|
'Fossilized Bone',
|
||||||
'/images/fossil-fish.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Bone';
|
FROM categories WHERE name = 'Bone';
|
||||||
|
|
||||||
-- Insert stick products
|
-- Insert stick products
|
||||||
|
|
@ -107,7 +107,7 @@ SELECT
|
||||||
8.3,
|
8.3,
|
||||||
'Driftwood',
|
'Driftwood',
|
||||||
'Tan/Gray',
|
'Tan/Gray',
|
||||||
'/images/driftwood.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Stick';
|
FROM categories WHERE name = 'Stick';
|
||||||
|
|
||||||
WITH categories AS (
|
WITH categories AS (
|
||||||
|
|
@ -124,7 +124,7 @@ SELECT
|
||||||
3.8,
|
3.8,
|
||||||
'Maple',
|
'Maple',
|
||||||
'Brown',
|
'Brown',
|
||||||
'/images/walking-stick.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Stick';
|
FROM categories WHERE name = 'Stick';
|
||||||
|
|
||||||
WITH categories AS (
|
WITH categories AS (
|
||||||
|
|
@ -141,7 +141,7 @@ SELECT
|
||||||
1.5,
|
1.5,
|
||||||
'Birch',
|
'Birch',
|
||||||
'White',
|
'White',
|
||||||
'/images/birch-branches.jpg'
|
NULL
|
||||||
FROM categories WHERE name = 'Stick';
|
FROM categories WHERE name = 'Stick';
|
||||||
|
|
||||||
-- Create a cart for testing
|
-- Create a cart for testing
|
||||||
|
|
|
||||||
|
|
@ -14,31 +14,3 @@ CREATE INDEX idx_product_images_product_id ON product_images(product_id);
|
||||||
|
|
||||||
-- Modify existing products table to remove single image_url field
|
-- Modify existing products table to remove single image_url field
|
||||||
ALTER TABLE products DROP COLUMN IF EXISTS image_url;
|
ALTER TABLE products DROP COLUMN IF EXISTS image_url;
|
||||||
|
|
||||||
-- Insert test images for existing products
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/amethyst-geode.jpg', 0, true FROM products WHERE name = 'Amethyst Geode';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/amethyst-geode-closeup.jpg', 1, false FROM products WHERE name = 'Amethyst Geode';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/labradorite.jpg', 0, true FROM products WHERE name = 'Polished Labradorite';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/turquoise.jpg', 0, true FROM products WHERE name = 'Raw Turquoise';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/deer-antler.jpg', 0, true FROM products WHERE name = 'Deer Antler';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/fossil-fish.jpg', 0, true FROM products WHERE name = 'Fossil Fish';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/driftwood.jpg', 0, true FROM products WHERE name = 'Driftwood Piece';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/walking-stick.jpg', 0, true FROM products WHERE name = 'Walking Stick';
|
|
||||||
|
|
||||||
INSERT INTO product_images (product_id, image_path, display_order, is_primary)
|
|
||||||
SELECT id, '/images/birch-branches.jpg', 0, true FROM products WHERE name = 'Decorative Branch Set';
|
|
||||||
|
|
@ -51,3 +51,11 @@ VALUES (
|
||||||
'email_templates'
|
'email_templates'
|
||||||
)
|
)
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO system_settings (key, value, category)
|
||||||
|
VALUES (
|
||||||
|
'email_template_refund_confirmation_default',
|
||||||
|
'{"name":"Refund Confirmation Template","type":"refund_confirmation","subject":"Your Refund for Order #{{order_id}} Has Been Processed","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;\">Refund Confirmation</h1><p style=\"font-size: 16px;\">Order #{{order_id}}</p></div><div style=\"padding: 20px;\"><p>Hello {{first_name}},</p><p>We are writing to confirm that your refund for order #{{order_id}} has been processed. The refund amount of {{refund_amount}} has been issued to your original payment method.</p><div style=\"background-color: #f8f8f8; padding: 15px; margin: 20px 0;\"><h3 style=\"margin-top: 0;\">Refund Details</h3><p><strong>Refund Amount:</strong> {{refund_amount}}</p><p><strong>Refund Date:</strong> {{refund_date}}</p><p><strong>Refund Method:</strong> {{refund_method}}</p><p><strong>Refund Reason:</strong> {{refund_reason}}</p></div><p>Please note that it may take 5-10 business days for the refund to appear in your account, depending on your payment provider.</p><p>If you have any questions about this refund, please do not hesitate to contact our customer support team.</p><p>Thank you for your understanding.</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-05-02T00:00:00.000Z"}',
|
||||||
|
'email_templates'
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
4
db/init/21-order-refund.sql
Normal file
4
db/init/21-order-refund.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- Add refund-related columns to order_items table
|
||||||
|
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS refunded BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS refunded_quantity INTEGER;
|
||||||
|
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS refund_reason TEXT;
|
||||||
|
|
@ -30,7 +30,7 @@ const Footer = ({brandingSettings}) => {
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={12} sm={4}>
|
||||||
{logoUrl ? (
|
{brandingSettings?.logo_url ? (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,10 @@ import {
|
||||||
Grid,
|
Grid,
|
||||||
Typography,
|
Typography,
|
||||||
Divider,
|
Divider,
|
||||||
Box
|
Box,
|
||||||
|
Alert
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useUpdateOrderStatus } from '@hooks/adminHooks';
|
import { useUpdateOrderStatus, useRefundOrder } from '@hooks/adminHooks';
|
||||||
|
|
||||||
// List of supported shipping carriers
|
// List of supported shipping carriers
|
||||||
const SHIPPING_CARRIERS = [
|
const SHIPPING_CARRIERS = [
|
||||||
|
|
@ -29,6 +30,18 @@ const SHIPPING_CARRIERS = [
|
||||||
{ value: 'other', label: 'Other (specify)' }
|
{ value: 'other', label: 'Other (specify)' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// List of refund reasons
|
||||||
|
const REFUND_REASONS = [
|
||||||
|
{ value: 'customer_request', label: 'Customer Request' },
|
||||||
|
{ value: 'item_damaged', label: 'Item Arrived Damaged' },
|
||||||
|
{ value: 'item_missing', label: 'Items Missing from Order' },
|
||||||
|
{ value: 'wrong_item', label: 'Wrong Item Received' },
|
||||||
|
{ value: 'quality_issue', label: 'Quality Not as Expected' },
|
||||||
|
{ value: 'late_delivery', label: 'Excessive Shipping Delay' },
|
||||||
|
{ value: 'order_mistake', label: 'Mistake in Order Processing' },
|
||||||
|
{ value: 'other', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
const OrderStatusDialog = ({ open, onClose, order }) => {
|
const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
const [status, setStatus] = useState(order ? order.status : 'pending');
|
const [status, setStatus] = useState(order ? order.status : 'pending');
|
||||||
const [shippingData, setShippingData] = useState({
|
const [shippingData, setShippingData] = useState({
|
||||||
|
|
@ -39,8 +52,14 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
estimatedDelivery: '',
|
estimatedDelivery: '',
|
||||||
customerMessage: ''
|
customerMessage: ''
|
||||||
});
|
});
|
||||||
|
const [refundData, setRefundData] = useState({
|
||||||
|
reason: 'customer_request',
|
||||||
|
customReason: '',
|
||||||
|
sendEmail: true
|
||||||
|
});
|
||||||
|
|
||||||
const updateOrderStatus = useUpdateOrderStatus();
|
const updateOrderStatus = useUpdateOrderStatus();
|
||||||
|
const refundOrder = useRefundOrder();
|
||||||
|
|
||||||
const handleStatusChange = (e) => {
|
const handleStatusChange = (e) => {
|
||||||
setStatus(e.target.value);
|
setStatus(e.target.value);
|
||||||
|
|
@ -54,6 +73,22 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefundDataChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setRefundData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (e) => {
|
||||||
|
const { name, checked } = e.target;
|
||||||
|
setRefundData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: checked
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// For shipped status, require tracking number
|
// For shipped status, require tracking number
|
||||||
if (status === 'shipped' && !shippingData.trackingNumber) {
|
if (status === 'shipped' && !shippingData.trackingNumber) {
|
||||||
|
|
@ -67,10 +102,28 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
shipperValue = shippingData.otherShipper;
|
shipperValue = shippingData.otherShipper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For refunded status, use the refund mutation
|
||||||
|
if (status === 'refunded') {
|
||||||
|
// Determine the actual reason value to send
|
||||||
|
let reasonValue = refundData.reason;
|
||||||
|
if (reasonValue === 'other' && refundData.customReason) {
|
||||||
|
reasonValue = refundData.customReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
refundOrder.mutate({
|
||||||
|
orderId: order.id,
|
||||||
|
reason: reasonValue,
|
||||||
|
sendEmail: refundData.sendEmail
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (status === 'shipped') {
|
||||||
|
// For shipped status, include shipping information
|
||||||
updateOrderStatus.mutate({
|
updateOrderStatus.mutate({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
status,
|
status,
|
||||||
...(status === 'shipped' && {
|
|
||||||
shippingData: {
|
shippingData: {
|
||||||
...shippingData,
|
...shippingData,
|
||||||
shipper: shipperValue,
|
shipper: shipperValue,
|
||||||
|
|
@ -78,18 +131,37 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
shippedDate: new Date().toISOString().split('T')[0]
|
shippedDate: new Date().toISOString().split('T')[0]
|
||||||
},
|
},
|
||||||
sendNotification: true
|
sendNotification: true
|
||||||
})
|
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// For other statuses, just update the status
|
||||||
|
updateOrderStatus.mutate({
|
||||||
|
orderId: order.id,
|
||||||
|
status
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPending = updateOrderStatus.isLoading || refundOrder.isLoading;
|
||||||
|
const error = updateOrderStatus.error || refundOrder.error;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||||
<DialogTitle>Update Order Status</DialogTitle>
|
<DialogTitle>Update Order Status</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error.message || 'An error occurred while updating the order.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
|
|
@ -205,6 +277,70 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
</Grid>
|
</Grid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status === 'refunded' && (
|
||||||
|
<>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Refund Information
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
The refund will be processed through Stripe to the customer's original payment method.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="refund-reason-label">Refund Reason</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="refund-reason-label"
|
||||||
|
name="reason"
|
||||||
|
value={refundData.reason}
|
||||||
|
label="Refund Reason"
|
||||||
|
onChange={handleRefundDataChange}
|
||||||
|
>
|
||||||
|
{REFUND_REASONS.map((reason) => (
|
||||||
|
<MenuItem key={reason.value} value={reason.value}>
|
||||||
|
{reason.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{refundData.reason === 'other' && (
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Specify Reason"
|
||||||
|
name="customReason"
|
||||||
|
value={refundData.customReason}
|
||||||
|
onChange={handleRefundDataChange}
|
||||||
|
placeholder="Enter specific reason for refund"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="sendEmail"
|
||||||
|
name="sendEmail"
|
||||||
|
checked={refundData.sendEmail}
|
||||||
|
onChange={(e) => handleCheckboxChange(e)}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="sendEmail">
|
||||||
|
Send refund confirmation email to customer
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
@ -213,9 +349,9 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={updateOrderStatus.isLoading}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
{updateOrderStatus.isLoading ? <CircularProgress size={24} /> : 'Save'}
|
{isPending ? <CircularProgress size={24} /> : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const ProductImage = ({
|
||||||
images,
|
images,
|
||||||
alt = 'Product image',
|
alt = 'Product image',
|
||||||
sx = {},
|
sx = {},
|
||||||
placeholderImage = '/placeholder.jpg',
|
placeholderImage = "https://placehold.co/600x400/000000/FFFF",
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,34 @@ export const useUpdateOrderStatus = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for processing a refund (admin only)
|
||||||
|
*/
|
||||||
|
export const useRefundOrder = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const notification = useNotification();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ orderId, reason, sendEmail, amount, refundItems }) => {
|
||||||
|
return adminOrderService.processRefund(
|
||||||
|
orderId,
|
||||||
|
reason,
|
||||||
|
sendEmail,
|
||||||
|
amount,
|
||||||
|
refundItems
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orders'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-order', data.order.id] });
|
||||||
|
notification.showNotification('Refund processed successfully', 'success');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
notification.showNotification(
|
||||||
|
error.message || 'Failed to process refund',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -44,11 +44,8 @@ const AdminLayout = () => {
|
||||||
const { data: brandingSettings } = useBrandingSettings();
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
|
|
||||||
// Get site name from branding settings or use default
|
|
||||||
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
|
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
|
// Force drawer closed on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ const AuthLayout = () => {
|
||||||
mb: 4,
|
mb: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logoUrl ? (
|
{brandingSettings?.logo_url ? (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ const MainLayout = () => {
|
||||||
const drawer = (
|
const drawer = (
|
||||||
<Box sx={{ width: 250 }} role="presentation" onClick={handleDrawerToggle}>
|
<Box sx={{ width: 250 }} role="presentation" onClick={handleDrawerToggle}>
|
||||||
<Box sx={{ display: 'flex', p: 2, alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', p: 2, alignItems: 'center' }}>
|
||||||
{logoUrl ? (
|
{brandingSettings?.logo_url ? (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
|
|
@ -157,7 +157,7 @@ const MainLayout = () => {
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{logoUrl ? (
|
{brandingSettings?.logo_url ? (
|
||||||
<Box
|
<Box
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to="/"
|
to="/"
|
||||||
|
|
|
||||||
|
|
@ -591,7 +591,100 @@ const AdminOrdersPage = () => {
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Refund Information - Only show for refunded orders */}
|
||||||
|
{orderDetails && orderDetails.status === 'refunded' && orderDetails.payment_notes && (
|
||||||
|
<Grid item xs={12} sx={{ mt: 2 }}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Refund Information
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// Parse refund info from JSON if needed
|
||||||
|
let refundInfo = null;
|
||||||
|
|
||||||
|
if (typeof orderDetails.payment_notes === 'string') {
|
||||||
|
try {
|
||||||
|
const paymentNotes = JSON.parse(orderDetails.payment_notes);
|
||||||
|
refundInfo = paymentNotes.refund;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse payment notes:', e);
|
||||||
|
}
|
||||||
|
} else if (orderDetails.payment_notes && orderDetails.payment_notes.refund) {
|
||||||
|
refundInfo = orderDetails.payment_notes.refund;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refundInfo) {
|
||||||
|
return (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No detailed refund information available.
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Refund Amount:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
${parseFloat(refundInfo.amount).toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Refund Date:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{formatDate(refundInfo.created)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Refund Status:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
<Chip
|
||||||
|
label={refundInfo.status || 'processed'}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Refund Reason:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{refundInfo.reason || 'Not specified'}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{refundInfo.refund_id && (
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Refund ID:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" sx={{ fontFamily: 'monospace' }}>
|
||||||
|
{refundInfo.refund_id}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
{/* Order Items */}
|
{/* Order Items */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Card variant="outlined">
|
<Card variant="outlined">
|
||||||
|
|
|
||||||
|
|
@ -227,10 +227,9 @@ const BlogPage = () => {
|
||||||
height="200"
|
height="200"
|
||||||
image={post.featured_image_path
|
image={post.featured_image_path
|
||||||
? imageUtils.getImageUrl(post.featured_image_path)
|
? imageUtils.getImageUrl(post.featured_image_path)
|
||||||
: '/images/placeholder.jpg'}
|
: "https://placehold.co/600x400/000000/FFFF"}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
{/* Category */}
|
{/* Category */}
|
||||||
{post.category_name && (
|
{post.category_name && (
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ const CartPage = () => {
|
||||||
<Card sx={{ height: '100%' }}>
|
<Card sx={{ height: '100%' }}>
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
image={imageUtils.getImageUrl(item.primary_image.path || '/images/placeholder.jpg')}
|
image={item.primary_image.path ? imageUtils.getImageUrl(item.primary_image.path) : "https://placehold.co/600x400/000000/FFFF"}
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
sx={{ height: 80, objectFit: 'cover' }}
|
sx={{ height: 80, objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const HomePage = () => {
|
||||||
py: 8,
|
py: 8,
|
||||||
mb: 6,
|
mb: 6,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), url(/images/hero-background.jpg)',
|
backgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
}}
|
}}
|
||||||
|
|
@ -72,7 +72,7 @@ const HomePage = () => {
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="200"
|
height="200"
|
||||||
image={imageUtils.getImageUrl(category.image_path)}
|
image={category.image_path ? imageUtils.getImageUrl(category.image_path) : "https://placehold.co/600x400/000000/FFFF"}
|
||||||
alt={category.name}
|
alt={category.name}
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -115,9 +115,9 @@ const HomePage = () => {
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="200"
|
height="200"
|
||||||
image={imageUtils.getImageUrl((product.images && product.images.length > 0)
|
image={(product.images && product.images.length > 0)
|
||||||
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
? imageUtils.getImageUrl(product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||||
: '/images/placeholder.jpg')}
|
) : "https://placehold.co/600x400/000000/FFFF"}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
/>
|
/>
|
||||||
<CardContent sx={{ flexGrow: 1 }}>
|
<CardContent sx={{ flexGrow: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -204,10 +204,9 @@ const ProductDetailPage = () => {
|
||||||
<Card>
|
<Card>
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
image={
|
image={product.images && product.images.length > 0
|
||||||
imageUtils.getImageUrl(product.images && product.images.length > 0
|
?
|
||||||
? product.images[selectedImage]?.path
|
imageUtils.getImageUrl(product.images[selectedImage]?.path) : "https://placehold.co/600x400/000000/FFFF"
|
||||||
: '/images/placeholder.jpg')
|
|
||||||
}
|
}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
sx={{
|
sx={{
|
||||||
|
|
|
||||||
|
|
@ -343,9 +343,9 @@ const ProductsPage = () => {
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
height="200"
|
height="200"
|
||||||
image={imageUtils.getImageUrl((product.images && product.images.length > 0)
|
image={(product.images && product.images.length > 0)
|
||||||
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
? imageUtils.getImageUrl( product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||||
: '/images/placeholder.jpg')}
|
) : "https://placehold.co/600x400/000000/FFFF"}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
sx={{ objectFit: 'cover' }}
|
sx={{ objectFit: 'cover' }}
|
||||||
onClick={() => navigate(`/products/${product.id}`)}
|
onClick={() => navigate(`/products/${product.id}`)}
|
||||||
|
|
|
||||||
|
|
@ -123,5 +123,28 @@ export const adminOrderService = {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error.response?.data || { message: 'An unknown error occurred' };
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a refund for an order (admin only)
|
||||||
|
* @param {string} id - Order ID
|
||||||
|
* @param {string} reason - Refund reason
|
||||||
|
* @param {boolean} [sendEmail=true] - Whether to send a confirmation email
|
||||||
|
* @param {number} [amount] - Optional amount for partial refunds
|
||||||
|
* @param {Array} [refundItems] - Optional array of items to refund
|
||||||
|
* @returns {Promise} Promise with the API response
|
||||||
|
*/
|
||||||
|
processRefund: async (id, reason, sendEmail = true, amount = null, refundItems = null) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/admin/orders/${id}/refund`, {
|
||||||
|
reason,
|
||||||
|
sendEmail,
|
||||||
|
...(amount && { amount }),
|
||||||
|
...(refundItems && { refundItems })
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error.response?.data || { message: 'An unknown error occurred' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -66,7 +66,7 @@ export const imageService = {
|
||||||
* @returns {string} The full image URL
|
* @returns {string} The full image URL
|
||||||
*/
|
*/
|
||||||
getImageUrl: (imagePath) => {
|
getImageUrl: (imagePath) => {
|
||||||
if (!imagePath) return '/images/placeholder.jpg';
|
if (!imagePath) return "https://placehold.co/600x400/000000/FFFF";
|
||||||
|
|
||||||
// If it's already a full URL, return it
|
// If it's already a full URL, return it
|
||||||
if (imagePath.startsWith('http')) return imagePath;
|
if (imagePath.startsWith('http')) return imagePath;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const imageUtils = {
|
||||||
* @param {string} [defaultImage] - Default image to use if the path is invalid
|
* @param {string} [defaultImage] - Default image to use if the path is invalid
|
||||||
* @returns {string} - The full image URL
|
* @returns {string} - The full image URL
|
||||||
*/
|
*/
|
||||||
getImageUrl: (imagePath, defaultImage = '/placeholder.jpg') => {
|
getImageUrl: (imagePath, defaultImage = "https://placehold.co/600x400/000000/FFFF") => {
|
||||||
if (!imagePath) return defaultImage;
|
if (!imagePath) return defaultImage;
|
||||||
|
|
||||||
// If it's already a complete URL, return it as is
|
// If it's already a complete URL, return it as is
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue