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) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
const { status, refundReason } = req.body;
|
||||
|
||||
if (!req.user.is_admin) {
|
||||
return res.status(403).json({
|
||||
|
|
@ -128,25 +127,308 @@ module.exports = (pool, query, authMiddleware) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Update order status
|
||||
const result = await query(`
|
||||
UPDATE orders
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *
|
||||
`, [status, id]);
|
||||
// Get order with customer information before updating
|
||||
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 (result.rows.length === 0) {
|
||||
if (orderResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: true,
|
||||
message: 'Order not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Order status updated successfully',
|
||||
order: result.rows[0]
|
||||
});
|
||||
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({
|
||||
message: 'Order status updated successfully',
|
||||
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) {
|
||||
next(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -604,6 +604,26 @@ const emailService = {
|
|||
// Don't throw error, just log it
|
||||
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',
|
||||
'Quartz',
|
||||
'Brazil',
|
||||
'/images/amethyst-geode.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Rock';
|
||||
|
||||
WITH categories AS (
|
||||
|
|
@ -41,7 +41,7 @@ SELECT
|
|||
'Gray/Blue',
|
||||
'Feldspar',
|
||||
'Madagascar',
|
||||
'/images/labradorite.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Rock';
|
||||
|
||||
WITH categories AS (
|
||||
|
|
@ -58,7 +58,7 @@ SELECT
|
|||
'Turquoise',
|
||||
'Turquoise',
|
||||
'Arizona',
|
||||
'/images/turquoise.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Rock';
|
||||
|
||||
-- Insert bone products
|
||||
|
|
@ -74,7 +74,7 @@ SELECT
|
|||
8,
|
||||
38.5,
|
||||
'Antler',
|
||||
'/images/deer-antler.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Bone';
|
||||
|
||||
WITH categories AS (
|
||||
|
|
@ -89,7 +89,7 @@ SELECT
|
|||
5,
|
||||
22.8,
|
||||
'Fossilized Bone',
|
||||
'/images/fossil-fish.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Bone';
|
||||
|
||||
-- Insert stick products
|
||||
|
|
@ -107,7 +107,7 @@ SELECT
|
|||
8.3,
|
||||
'Driftwood',
|
||||
'Tan/Gray',
|
||||
'/images/driftwood.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Stick';
|
||||
|
||||
WITH categories AS (
|
||||
|
|
@ -124,7 +124,7 @@ SELECT
|
|||
3.8,
|
||||
'Maple',
|
||||
'Brown',
|
||||
'/images/walking-stick.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Stick';
|
||||
|
||||
WITH categories AS (
|
||||
|
|
@ -141,7 +141,7 @@ SELECT
|
|||
1.5,
|
||||
'Birch',
|
||||
'White',
|
||||
'/images/birch-branches.jpg'
|
||||
NULL
|
||||
FROM categories WHERE name = 'Stick';
|
||||
|
||||
-- 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
|
||||
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'
|
||||
)
|
||||
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">
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
{logoUrl ? (
|
||||
{brandingSettings?.logo_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoUrl}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ import {
|
|||
Grid,
|
||||
Typography,
|
||||
Divider,
|
||||
Box
|
||||
Box,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { useUpdateOrderStatus } from '@hooks/adminHooks';
|
||||
import { useUpdateOrderStatus, useRefundOrder } from '@hooks/adminHooks';
|
||||
|
||||
// List of supported shipping carriers
|
||||
const SHIPPING_CARRIERS = [
|
||||
|
|
@ -29,6 +30,18 @@ const SHIPPING_CARRIERS = [
|
|||
{ 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 [status, setStatus] = useState(order ? order.status : 'pending');
|
||||
const [shippingData, setShippingData] = useState({
|
||||
|
|
@ -39,8 +52,14 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
|||
estimatedDelivery: '',
|
||||
customerMessage: ''
|
||||
});
|
||||
const [refundData, setRefundData] = useState({
|
||||
reason: 'customer_request',
|
||||
customReason: '',
|
||||
sendEmail: true
|
||||
});
|
||||
|
||||
const updateOrderStatus = useUpdateOrderStatus();
|
||||
const refundOrder = useRefundOrder();
|
||||
|
||||
const handleStatusChange = (e) => {
|
||||
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 = () => {
|
||||
// For shipped status, require tracking number
|
||||
if (status === 'shipped' && !shippingData.trackingNumber) {
|
||||
|
|
@ -67,10 +102,28 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
|||
shipperValue = shippingData.otherShipper;
|
||||
}
|
||||
|
||||
updateOrderStatus.mutate({
|
||||
orderId: order.id,
|
||||
status,
|
||||
...(status === 'shipped' && {
|
||||
// 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({
|
||||
orderId: order.id,
|
||||
status,
|
||||
shippingData: {
|
||||
...shippingData,
|
||||
shipper: shipperValue,
|
||||
|
|
@ -78,18 +131,37 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
|||
shippedDate: new Date().toISOString().split('T')[0]
|
||||
},
|
||||
sendNotification: true
|
||||
})
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Update Order Status</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error.message || 'An error occurred while updating the order.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<FormControl fullWidth>
|
||||
|
|
@ -205,6 +277,70 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
|||
</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>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
|
@ -213,9 +349,9 @@ const OrderStatusDialog = ({ open, onClose, order }) => {
|
|||
onClick={handleSave}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={updateOrderStatus.isLoading}
|
||||
disabled={isPending}
|
||||
>
|
||||
{updateOrderStatus.isLoading ? <CircularProgress size={24} /> : 'Save'}
|
||||
{isPending ? <CircularProgress size={24} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const ProductImage = ({
|
|||
images,
|
||||
alt = 'Product image',
|
||||
sx = {},
|
||||
placeholderImage = '/placeholder.jpg',
|
||||
placeholderImage = "https://placehold.co/600x400/000000/FFFF",
|
||||
...rest
|
||||
}) => {
|
||||
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();
|
||||
|
||||
|
||||
// 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(() => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const AuthLayout = () => {
|
|||
mb: 4,
|
||||
}}
|
||||
>
|
||||
{logoUrl ? (
|
||||
{brandingSettings?.logo_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoUrl}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const MainLayout = () => {
|
|||
const drawer = (
|
||||
<Box sx={{ width: 250 }} role="presentation" onClick={handleDrawerToggle}>
|
||||
<Box sx={{ display: 'flex', p: 2, alignItems: 'center' }}>
|
||||
{logoUrl ? (
|
||||
{brandingSettings?.logo_url ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={logoUrl}
|
||||
|
|
@ -157,7 +157,7 @@ const MainLayout = () => {
|
|||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
{logoUrl ? (
|
||||
{brandingSettings?.logo_url ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
|
|
|
|||
|
|
@ -591,7 +591,100 @@ const AdminOrdersPage = () => {
|
|||
</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 */}
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
|
|
|
|||
|
|
@ -227,10 +227,9 @@ const BlogPage = () => {
|
|||
height="200"
|
||||
image={post.featured_image_path
|
||||
? imageUtils.getImageUrl(post.featured_image_path)
|
||||
: '/images/placeholder.jpg'}
|
||||
: "https://placehold.co/600x400/000000/FFFF"}
|
||||
alt={post.title}
|
||||
/>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
{/* Category */}
|
||||
{post.category_name && (
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ const CartPage = () => {
|
|||
<Card sx={{ height: '100%' }}>
|
||||
<CardMedia
|
||||
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}
|
||||
sx={{ height: 80, objectFit: 'cover' }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const HomePage = () => {
|
|||
py: 8,
|
||||
mb: 6,
|
||||
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',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
|
|
@ -72,7 +72,7 @@ const HomePage = () => {
|
|||
<CardMedia
|
||||
component="img"
|
||||
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}
|
||||
/>
|
||||
<CardContent>
|
||||
|
|
@ -115,9 +115,9 @@ const HomePage = () => {
|
|||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={imageUtils.getImageUrl((product.images && product.images.length > 0)
|
||||
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||
: '/images/placeholder.jpg')}
|
||||
image={(product.images && product.images.length > 0)
|
||||
? imageUtils.getImageUrl(product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||
) : "https://placehold.co/600x400/000000/FFFF"}
|
||||
alt={product.name}
|
||||
/>
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
|
|
|
|||
|
|
@ -204,10 +204,9 @@ const ProductDetailPage = () => {
|
|||
<Card>
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={
|
||||
imageUtils.getImageUrl(product.images && product.images.length > 0
|
||||
? product.images[selectedImage]?.path
|
||||
: '/images/placeholder.jpg')
|
||||
image={product.images && product.images.length > 0
|
||||
?
|
||||
imageUtils.getImageUrl(product.images[selectedImage]?.path) : "https://placehold.co/600x400/000000/FFFF"
|
||||
}
|
||||
alt={product.name}
|
||||
sx={{
|
||||
|
|
|
|||
|
|
@ -343,9 +343,9 @@ const ProductsPage = () => {
|
|||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={imageUtils.getImageUrl((product.images && product.images.length > 0)
|
||||
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||
: '/images/placeholder.jpg')}
|
||||
image={(product.images && product.images.length > 0)
|
||||
? imageUtils.getImageUrl( product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||
) : "https://placehold.co/600x400/000000/FFFF"}
|
||||
alt={product.name}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
|
|
|
|||
|
|
@ -123,5 +123,28 @@ export const adminOrderService = {
|
|||
} catch (error) {
|
||||
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
|
||||
*/
|
||||
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 (imagePath.startsWith('http')) return imagePath;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const imageUtils = {
|
|||
* @param {string} [defaultImage] - Default image to use if the path is invalid
|
||||
* @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 it's already a complete URL, return it as is
|
||||
|
|
|
|||
Loading…
Reference in a new issue