shippinh integration
This commit is contained in:
parent
ceb26c6524
commit
aa2a97bbad
9 changed files with 1017 additions and 153 deletions
|
|
@ -35,6 +35,30 @@ const config = {
|
||||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || ''
|
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || ''
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Shipping configuration
|
||||||
|
shipping: {
|
||||||
|
enabled: process.env.SHIPPING_ENABLED === 'true',
|
||||||
|
easypostEnabled: process.env.EASYPOST_ENABLED === 'true',
|
||||||
|
easypostApiKey: process.env.EASYPOST_API_KEY || '',
|
||||||
|
flatRate: parseFloat(process.env.SHIPPING_FLAT_RATE || '10.00'),
|
||||||
|
freeThreshold: parseFloat(process.env.SHIPPING_FREE_THRESHOLD || '50.00'),
|
||||||
|
originAddress: {
|
||||||
|
street: process.env.SHIPPING_ORIGIN_STREET || '123 Main St',
|
||||||
|
city: process.env.SHIPPING_ORIGIN_CITY || 'Vancouver',
|
||||||
|
state: process.env.SHIPPING_ORIGIN_STATE || 'BC',
|
||||||
|
zip: process.env.SHIPPING_ORIGIN_ZIP || 'V6K 1V6',
|
||||||
|
country: process.env.SHIPPING_ORIGIN_COUNTRY || 'CA'
|
||||||
|
},
|
||||||
|
defaultPackage: {
|
||||||
|
length: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_LENGTH || '15'),
|
||||||
|
width: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_WIDTH || '12'),
|
||||||
|
height: parseFloat(process.env.SHIPPING_DEFAULT_PACKAGE_HEIGHT || '10'),
|
||||||
|
unit: process.env.SHIPPING_DEFAULT_PACKAGE_UNIT || 'cm',
|
||||||
|
weightUnit: process.env.SHIPPING_DEFAULT_WEIGHT_UNIT || 'g'
|
||||||
|
},
|
||||||
|
carriersAllowed: (process.env.SHIPPING_CARRIERS_ALLOWED || 'USPS,UPS,FedEx,DHL,Canada Post,Purolator').split(',')
|
||||||
|
},
|
||||||
|
|
||||||
// Site configuration (domain and protocol based on environment)
|
// Site configuration (domain and protocol based on environment)
|
||||||
site: {
|
site: {
|
||||||
domain: process.env.ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000',
|
domain: process.env.ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000',
|
||||||
|
|
@ -78,6 +102,50 @@ config.updateFromDatabase = (settings) => {
|
||||||
if (stripeWebhook && stripeWebhook.value) config.payment.stripeWebhookSecret = stripeWebhook.value;
|
if (stripeWebhook && stripeWebhook.value) config.payment.stripeWebhookSecret = stripeWebhook.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update shipping settings if they exist in DB
|
||||||
|
const shippingSettings = settings.filter(s => s.category === 'shipping');
|
||||||
|
if (shippingSettings.length > 0) {
|
||||||
|
const shippingEnabled = shippingSettings.find(s => s.key === 'shipping_enabled');
|
||||||
|
const easypostEnabled = shippingSettings.find(s => s.key === 'easypost_enabled');
|
||||||
|
const easypostApiKey = shippingSettings.find(s => s.key === 'easypost_api_key');
|
||||||
|
const flatRate = shippingSettings.find(s => s.key === 'shipping_flat_rate');
|
||||||
|
const freeThreshold = shippingSettings.find(s => s.key === 'shipping_free_threshold');
|
||||||
|
const originStreet = shippingSettings.find(s => s.key === 'shipping_origin_street');
|
||||||
|
const originCity = shippingSettings.find(s => s.key === 'shipping_origin_city');
|
||||||
|
const originState = shippingSettings.find(s => s.key === 'shipping_origin_state');
|
||||||
|
const originZip = shippingSettings.find(s => s.key === 'shipping_origin_zip');
|
||||||
|
const originCountry = shippingSettings.find(s => s.key === 'shipping_origin_country');
|
||||||
|
const packageLength = shippingSettings.find(s => s.key === 'shipping_default_package_length');
|
||||||
|
const packageWidth = shippingSettings.find(s => s.key === 'shipping_default_package_width');
|
||||||
|
const packageHeight = shippingSettings.find(s => s.key === 'shipping_default_package_height');
|
||||||
|
const packageUnit = shippingSettings.find(s => s.key === 'shipping_default_package_unit');
|
||||||
|
const weightUnit = shippingSettings.find(s => s.key === 'shipping_default_weight_unit');
|
||||||
|
const carriersAllowed = shippingSettings.find(s => s.key === 'shipping_carriers_allowed');
|
||||||
|
|
||||||
|
if (shippingEnabled && shippingEnabled.value) config.shipping.enabled = shippingEnabled.value === 'true';
|
||||||
|
if (easypostEnabled && easypostEnabled.value) config.shipping.easypostEnabled = easypostEnabled.value === 'true';
|
||||||
|
if (easypostApiKey && easypostApiKey.value) config.shipping.easypostApiKey = easypostApiKey.value;
|
||||||
|
if (flatRate && flatRate.value) config.shipping.flatRate = parseFloat(flatRate.value);
|
||||||
|
if (freeThreshold && freeThreshold.value) config.shipping.freeThreshold = parseFloat(freeThreshold.value);
|
||||||
|
|
||||||
|
// Update origin address
|
||||||
|
if (originStreet && originStreet.value) config.shipping.originAddress.street = originStreet.value;
|
||||||
|
if (originCity && originCity.value) config.shipping.originAddress.city = originCity.value;
|
||||||
|
if (originState && originState.value) config.shipping.originAddress.state = originState.value;
|
||||||
|
if (originZip && originZip.value) config.shipping.originAddress.zip = originZip.value;
|
||||||
|
if (originCountry && originCountry.value) config.shipping.originAddress.country = originCountry.value;
|
||||||
|
|
||||||
|
// Update default package
|
||||||
|
if (packageLength && packageLength.value) config.shipping.defaultPackage.length = parseFloat(packageLength.value);
|
||||||
|
if (packageWidth && packageWidth.value) config.shipping.defaultPackage.width = parseFloat(packageWidth.value);
|
||||||
|
if (packageHeight && packageHeight.value) config.shipping.defaultPackage.height = parseFloat(packageHeight.value);
|
||||||
|
if (packageUnit && packageUnit.value) config.shipping.defaultPackage.unit = packageUnit.value;
|
||||||
|
if (weightUnit && weightUnit.value) config.shipping.defaultPackage.weightUnit = weightUnit.value;
|
||||||
|
|
||||||
|
// Update carriers allowed
|
||||||
|
if (carriersAllowed && carriersAllowed.value) config.shipping.carriersAllowed = carriersAllowed.value.split(',');
|
||||||
|
}
|
||||||
|
|
||||||
// Update site settings if they exist in DB
|
// Update site settings if they exist in DB
|
||||||
const siteSettings = settings.filter(s => s.category === 'site');
|
const siteSettings = settings.filter(s => s.category === 'site');
|
||||||
if (siteSettings.length > 0) {
|
if (siteSettings.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ const productRoutes = require('./routes/products');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const cartRoutes = require('./routes/cart');
|
const cartRoutes = require('./routes/cart');
|
||||||
const productAdminRoutes = require('./routes/productAdmin');
|
const productAdminRoutes = require('./routes/productAdmin');
|
||||||
const categoryAdminRoutes = require('./routes/categoryAdmin'); // Add category admin routes
|
const categoryAdminRoutes = require('./routes/categoryAdmin');
|
||||||
const usersAdminRoutes = require('./routes/userAdmin');
|
const usersAdminRoutes = require('./routes/userAdmin');
|
||||||
const ordersAdminRoutes = require('./routes/orderAdmin');
|
const ordersAdminRoutes = require('./routes/orderAdmin');
|
||||||
const userOrdersRoutes = require('./routes/userOrders');
|
const userOrdersRoutes = require('./routes/userOrders');
|
||||||
|
const shippingRoutes = require('./routes/shipping');
|
||||||
|
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = config.port || 4000;
|
const port = config.port || 4000;
|
||||||
|
|
@ -240,8 +242,6 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Use routes
|
// Use routes
|
||||||
app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
app.use('/api/products', productRoutes(pool, query));
|
app.use('/api/products', productRoutes(pool, query));
|
||||||
|
|
@ -249,8 +249,9 @@ app.use('/api/auth', authRoutes(pool, query));
|
||||||
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
|
app.use('/api/user/orders', userOrdersRoutes(pool, query, authMiddleware(pool, query)));
|
||||||
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
|
app.use('/api/cart', cartRoutes(pool, query, authMiddleware(pool, query)));
|
||||||
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
app.use('/api/admin/products', productAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
|
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
|
app.use('/api/shipping', shippingRoutes(pool, query, authMiddleware(pool, query)));
|
||||||
|
|
||||||
app.use('/api/admin/categories', categoryAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); // Add category admin routes
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const shippingService = require('../services/shippingService');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
module.exports = (pool, query, authMiddleware) => {
|
module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
|
|
@ -37,6 +39,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
`SELECT ci.id, ci.quantity, ci.added_at,
|
`SELECT ci.id, ci.quantity, ci.added_at,
|
||||||
p.id AS product_id, p.name, p.description, p.price,
|
p.id AS product_id, p.name, p.description, p.price,
|
||||||
p.category_id, pc.name AS category_name,
|
p.category_id, pc.name AS category_name,
|
||||||
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
||||||
(
|
(
|
||||||
SELECT json_agg(
|
SELECT json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
|
|
@ -53,7 +56,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
JOIN products p ON ci.product_id = p.id
|
JOIN products p ON ci.product_id = p.id
|
||||||
JOIN product_categories pc ON p.category_id = pc.id
|
JOIN product_categories pc ON p.category_id = pc.id
|
||||||
WHERE ci.cart_id = $1
|
WHERE ci.cart_id = $1
|
||||||
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.category_id, pc.name`,
|
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
|
||||||
[cartId]
|
[cartId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -72,16 +75,28 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total
|
// Calculate total
|
||||||
const total = processedItems.reduce((sum, item) => {
|
const subtotal = processedItems.reduce((sum, item) => {
|
||||||
return sum + (parseFloat(item.price) * item.quantity);
|
return sum + (parseFloat(item.price) * item.quantity);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// Initialize shipping
|
||||||
|
const shipping = {
|
||||||
|
rates: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate basic flat rate shipping
|
||||||
|
if (config.shipping.enabled) {
|
||||||
|
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: cartId,
|
id: cartId,
|
||||||
userId,
|
userId,
|
||||||
items: processedItems,
|
items: processedItems,
|
||||||
itemCount: processedItems.length,
|
itemCount: processedItems.length,
|
||||||
total
|
subtotal,
|
||||||
|
shipping,
|
||||||
|
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
@ -179,6 +194,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
`SELECT ci.id, ci.quantity, ci.added_at,
|
`SELECT ci.id, ci.quantity, ci.added_at,
|
||||||
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
|
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
|
||||||
p.category_id, pc.name AS category_name,
|
p.category_id, pc.name AS category_name,
|
||||||
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
||||||
(
|
(
|
||||||
SELECT json_agg(
|
SELECT json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
|
|
@ -195,7 +211,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
JOIN products p ON ci.product_id = p.id
|
JOIN products p ON ci.product_id = p.id
|
||||||
JOIN product_categories pc ON p.category_id = pc.id
|
JOIN product_categories pc ON p.category_id = pc.id
|
||||||
WHERE ci.cart_id = $1
|
WHERE ci.cart_id = $1
|
||||||
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name`,
|
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
|
||||||
[cartId]
|
[cartId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -213,17 +229,29 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total
|
// Calculate subtotal
|
||||||
const total = processedItems.reduce((sum, item) => {
|
const subtotal = processedItems.reduce((sum, item) => {
|
||||||
return sum + (parseFloat(item.price) * item.quantity);
|
return sum + (parseFloat(item.price) * item.quantity);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// Initialize shipping
|
||||||
|
const shipping = {
|
||||||
|
rates: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate basic flat rate shipping
|
||||||
|
if (config.shipping.enabled) {
|
||||||
|
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: cartId,
|
id: cartId,
|
||||||
userId,
|
userId,
|
||||||
items: processedItems,
|
items: processedItems,
|
||||||
itemCount: processedItems.length,
|
itemCount: processedItems.length,
|
||||||
total
|
subtotal,
|
||||||
|
shipping,
|
||||||
|
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
@ -299,6 +327,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
`SELECT ci.id, ci.quantity, ci.added_at,
|
`SELECT ci.id, ci.quantity, ci.added_at,
|
||||||
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
|
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
|
||||||
p.category_id, pc.name AS category_name,
|
p.category_id, pc.name AS category_name,
|
||||||
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
||||||
(
|
(
|
||||||
SELECT json_agg(
|
SELECT json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
|
|
@ -315,7 +344,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
JOIN products p ON ci.product_id = p.id
|
JOIN products p ON ci.product_id = p.id
|
||||||
JOIN product_categories pc ON p.category_id = pc.id
|
JOIN product_categories pc ON p.category_id = pc.id
|
||||||
WHERE ci.cart_id = $1
|
WHERE ci.cart_id = $1
|
||||||
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name`,
|
GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
|
||||||
[cartId]
|
[cartId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -333,17 +362,29 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate total
|
// Calculate subtotal
|
||||||
const total = processedItems.reduce((sum, item) => {
|
const subtotal = processedItems.reduce((sum, item) => {
|
||||||
return sum + (parseFloat(item.price) * item.quantity);
|
return sum + (parseFloat(item.price) * item.quantity);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// Initialize shipping
|
||||||
|
const shipping = {
|
||||||
|
rates: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate basic flat rate shipping
|
||||||
|
if (config.shipping.enabled) {
|
||||||
|
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: cartId,
|
id: cartId,
|
||||||
userId,
|
userId,
|
||||||
items: processedItems,
|
items: processedItems,
|
||||||
itemCount: processedItems.length,
|
itemCount: processedItems.length,
|
||||||
total
|
subtotal,
|
||||||
|
shipping,
|
||||||
|
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
@ -386,6 +427,10 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
userId,
|
userId,
|
||||||
items: [],
|
items: [],
|
||||||
itemCount: 0,
|
itemCount: 0,
|
||||||
|
subtotal: 0,
|
||||||
|
shipping: {
|
||||||
|
rates: []
|
||||||
|
},
|
||||||
total: 0
|
total: 0
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -393,10 +438,101 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/checkout', async (req, res, next) => {
|
// Get shipping rates for current cart
|
||||||
|
router.post('/shipping-rates', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { userId, shippingAddress } = req.body;
|
const { userId, shippingAddress } = req.body;
|
||||||
|
|
||||||
|
if (req.user.id !== userId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: true,
|
||||||
|
message: 'You can only get shipping rates for your own cart'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shipping must be enabled
|
||||||
|
if (!config.shipping.enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Shipping is currently disabled'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get cart
|
||||||
|
const cartResult = await query(
|
||||||
|
'SELECT * FROM carts WHERE user_id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cartResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Cart not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartId = cartResult.rows[0].id;
|
||||||
|
|
||||||
|
// Get cart items with product weights
|
||||||
|
const cartItemsResult = await query(
|
||||||
|
`SELECT ci.quantity, p.id, p.weight_grams, p.price
|
||||||
|
FROM cart_items ci
|
||||||
|
JOIN products p ON ci.product_id = p.id
|
||||||
|
WHERE ci.cart_id = $1`,
|
||||||
|
[cartId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cartItemsResult.rows.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Cart is empty'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total weight and order value
|
||||||
|
const totalWeight = shippingService.calculateTotalWeight(cartItemsResult.rows);
|
||||||
|
const subtotal = cartItemsResult.rows.reduce((sum, item) => {
|
||||||
|
return sum + (parseFloat(item.price) * item.quantity);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// If no address provided, return only flat rate shipping
|
||||||
|
if (!shippingAddress) {
|
||||||
|
const rates = await shippingService.getFlatRateShipping(subtotal);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
rates
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get real shipping rates
|
||||||
|
const parsedAddress = typeof shippingAddress === 'string'
|
||||||
|
? shippingService.parseAddressString(shippingAddress)
|
||||||
|
: shippingAddress;
|
||||||
|
|
||||||
|
const rates = await shippingService.getShippingRates(
|
||||||
|
null, // Use default from config
|
||||||
|
parsedAddress,
|
||||||
|
{
|
||||||
|
weight: totalWeight,
|
||||||
|
order_total: subtotal
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
rates
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting shipping rates:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/checkout', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { userId, shippingAddress, shippingMethod } = req.body;
|
||||||
|
|
||||||
if (req.user.id !== userId) {
|
if (req.user.id !== userId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: true,
|
error: true,
|
||||||
|
|
@ -421,7 +557,7 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
// Get cart items
|
// Get cart items
|
||||||
const cartItemsResult = await query(
|
const cartItemsResult = await query(
|
||||||
`SELECT ci.*, p.price, p.name, p.description,
|
`SELECT ci.*, p.price, p.name, p.description, p.weight_grams,
|
||||||
(
|
(
|
||||||
SELECT json_build_object(
|
SELECT json_build_object(
|
||||||
'path', pi.image_path,
|
'path', pi.image_path,
|
||||||
|
|
@ -444,11 +580,28 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total
|
// Calculate subtotal
|
||||||
const total = cartItemsResult.rows.reduce((sum, item) => {
|
const subtotal = cartItemsResult.rows.reduce((sum, item) => {
|
||||||
return sum + (parseFloat(item.price) * item.quantity);
|
return sum + (parseFloat(item.price) * item.quantity);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// Determine shipping cost
|
||||||
|
let shippingCost = 0;
|
||||||
|
|
||||||
|
if (config.shipping.enabled) {
|
||||||
|
// If a specific shipping method was selected
|
||||||
|
if (shippingMethod && shippingMethod.id) {
|
||||||
|
shippingCost = parseFloat(shippingMethod.rate) || 0;
|
||||||
|
} else {
|
||||||
|
// Default to flat rate
|
||||||
|
const shippingRates = await shippingService.getFlatRateShipping(subtotal);
|
||||||
|
shippingCost = shippingRates[0].rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total with shipping
|
||||||
|
const total = subtotal + shippingCost;
|
||||||
|
|
||||||
// Begin transaction
|
// Begin transaction
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
|
@ -458,8 +611,8 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
// Create order
|
// Create order
|
||||||
const orderId = uuidv4();
|
const orderId = uuidv4();
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO orders (id, user_id, status, total_amount, shipping_address, payment_completed) VALUES ($1, $2, $3, $4, $5, $6)',
|
'INSERT INTO orders (id, user_id, status, total_amount, shipping_address, payment_completed, shipping_cost) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
[orderId, userId, 'pending', total, shippingAddress, false]
|
[orderId, userId, 'pending', total, shippingAddress, false, shippingCost]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create order items
|
// Create order items
|
||||||
|
|
@ -470,6 +623,22 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a shipping method was selected, save it with the order
|
||||||
|
if (shippingMethod && shippingMethod.id) {
|
||||||
|
const shippingInfo = {
|
||||||
|
method_id: shippingMethod.id,
|
||||||
|
carrier: shippingMethod.carrier,
|
||||||
|
service: shippingMethod.service,
|
||||||
|
rate: shippingMethod.rate,
|
||||||
|
estimated_days: shippingMethod.delivery_days
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE orders SET shipping_info = $1 WHERE id = $2',
|
||||||
|
[JSON.stringify(shippingInfo), orderId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
// Send back cart items for Stripe checkout
|
// Send back cart items for Stripe checkout
|
||||||
|
|
@ -478,6 +647,8 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
message: 'Order created successfully, ready for payment',
|
message: 'Order created successfully, ready for payment',
|
||||||
orderId,
|
orderId,
|
||||||
cartItems: cartItemsResult.rows,
|
cartItems: cartItemsResult.rows,
|
||||||
|
subtotal,
|
||||||
|
shippingCost,
|
||||||
total
|
total
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
179
backend/src/routes/shipping.js
Normal file
179
backend/src/routes/shipping.js
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const shippingService = require('../services/shippingService');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
module.exports = (pool, query, authMiddleware) => {
|
||||||
|
// Apply authentication middleware to all routes
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shipping rates
|
||||||
|
* POST /api/shipping/rates
|
||||||
|
*
|
||||||
|
* Request Body:
|
||||||
|
* {
|
||||||
|
* address: {
|
||||||
|
* name: string,
|
||||||
|
* street: string,
|
||||||
|
* city: string,
|
||||||
|
* state: string,
|
||||||
|
* zip: string,
|
||||||
|
* country: string,
|
||||||
|
* email: string
|
||||||
|
* },
|
||||||
|
* parcel: {
|
||||||
|
* length: number,
|
||||||
|
* width: number,
|
||||||
|
* height: number,
|
||||||
|
* weight: number,
|
||||||
|
* order_total: number
|
||||||
|
* },
|
||||||
|
* items: [{ id, quantity, weight_grams }] // Optional cart items for weight calculation
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post('/rates', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { address, parcel, items } = req.body;
|
||||||
|
|
||||||
|
// Shipping must be enabled
|
||||||
|
if (!config.shipping.enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Shipping is currently disabled'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!address || !parcel) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Address and parcel information are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If address is a string, parse it
|
||||||
|
const parsedAddress = typeof address === 'string'
|
||||||
|
? shippingService.parseAddressString(address)
|
||||||
|
: address;
|
||||||
|
|
||||||
|
// Calculate total weight if items are provided
|
||||||
|
if (items && items.length > 0) {
|
||||||
|
parcel.weight = shippingService.calculateTotalWeight(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shipping rates
|
||||||
|
const rates = await shippingService.getShippingRates(
|
||||||
|
null, // Use default from config
|
||||||
|
parsedAddress,
|
||||||
|
{
|
||||||
|
...parcel,
|
||||||
|
order_total: parcel.order_total || 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
rates
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting shipping rates:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate shipping address
|
||||||
|
* POST /api/shipping/validate-address
|
||||||
|
*
|
||||||
|
* Request Body:
|
||||||
|
* {
|
||||||
|
* address: {
|
||||||
|
* name: string,
|
||||||
|
* street: string,
|
||||||
|
* city: string,
|
||||||
|
* state: string,
|
||||||
|
* zip: string,
|
||||||
|
* country: string,
|
||||||
|
* email: string
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post('/validate-address', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { address } = req.body;
|
||||||
|
|
||||||
|
// Shipping must be enabled
|
||||||
|
if (!config.shipping.enabled) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Shipping is currently disabled'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!address) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: true,
|
||||||
|
message: 'Address information is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If EasyPost is not enabled, just perform basic validation
|
||||||
|
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
|
||||||
|
const isValid = validateAddressFormat(address);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
valid: isValid,
|
||||||
|
original_address: address,
|
||||||
|
verified_address: address
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If address is a string, parse it
|
||||||
|
const parsedAddress = typeof address === 'string'
|
||||||
|
? shippingService.parseAddressString(address)
|
||||||
|
: address;
|
||||||
|
|
||||||
|
// TODO: Implement EasyPost address verification
|
||||||
|
// This would require making a call to EasyPost's API
|
||||||
|
// For now, we'll return the original address
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
valid: true,
|
||||||
|
original_address: parsedAddress,
|
||||||
|
verified_address: parsedAddress
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error validating address:', error);
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic address format validation
|
||||||
|
* @param {Object} address - Address to validate
|
||||||
|
* @returns {boolean} Whether the address has valid format
|
||||||
|
*/
|
||||||
|
function validateAddressFormat(address) {
|
||||||
|
// Check required fields
|
||||||
|
if (!address.street || !address.city || !address.zip || !address.country) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check zip code format - just basic validation
|
||||||
|
if (address.country === 'US' && !/^\d{5}(-\d{4})?$/.test(address.zip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.country === 'CA' && !/^[A-Za-z]\d[A-Za-z] \d[A-Za-z]\d$/.test(address.zip)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -40,5 +40,18 @@ VALUES
|
||||||
-- Shipping Settings
|
-- Shipping Settings
|
||||||
('shipping_flat_rate', '10.00', 'shipping'),
|
('shipping_flat_rate', '10.00', 'shipping'),
|
||||||
('shipping_free_threshold', '50.00', 'shipping'),
|
('shipping_free_threshold', '50.00', 'shipping'),
|
||||||
('shipping_enabled', 'true', 'shipping')
|
('shipping_enabled', 'true', 'shipping'),
|
||||||
|
('easypost_api_key', NULL, 'shipping'),
|
||||||
|
('easypost_enabled', 'false', 'shipping'),
|
||||||
|
('shipping_origin_street', '123 Main St', 'shipping'),
|
||||||
|
('shipping_origin_city', 'Vancouver', 'shipping'),
|
||||||
|
('shipping_origin_state', 'BC', 'shipping'),
|
||||||
|
('shipping_origin_zip', 'V6K 1V6', 'shipping'),
|
||||||
|
('shipping_origin_country', 'CA', 'shipping'),
|
||||||
|
('shipping_default_package_length', '15', 'shipping'),
|
||||||
|
('shipping_default_package_width', '12', 'shipping'),
|
||||||
|
('shipping_default_package_height', '10', 'shipping'),
|
||||||
|
('shipping_default_package_unit', 'cm', 'shipping'),
|
||||||
|
('shipping_default_weight_unit', 'g', 'shipping'),
|
||||||
|
('shipping_carriers_allowed', 'USPS,UPS,FedEx,DHL,Canada Post,Purolator', 'shipping')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
10
db/init/12-shipping-orders.sql
Normal file
10
db/init/12-shipping-orders.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
-- Add shipping cost column to orders table
|
||||||
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_cost DECIMAL(10, 2) DEFAULT 0.00;
|
||||||
|
|
||||||
|
-- Update shipping info to be JSONB if not already
|
||||||
|
ALTER TABLE orders ALTER COLUMN shipping_info TYPE JSONB
|
||||||
|
USING CASE
|
||||||
|
WHEN shipping_info IS NULL THEN NULL
|
||||||
|
WHEN jsonb_typeof(shipping_info::jsonb) = 'object' THEN shipping_info::jsonb
|
||||||
|
ELSE jsonb_build_object('data', shipping_info)
|
||||||
|
END;
|
||||||
|
|
@ -1,117 +1,136 @@
|
||||||
Rocks/
|
Rocks/
|
||||||
├── .git/ # Git repository
|
|
||||||
├── .env # Environment configuration
|
|
||||||
├── .gitignore # Git ignore file
|
|
||||||
├── README.md # Project documentation
|
|
||||||
├── Dockerfile # Main Dockerfile
|
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
|
||||||
├── nginx.conf # Nginx configuration
|
|
||||||
├── setup-frontend.sh # Frontend setup script
|
|
||||||
├── package.json # Project dependencies
|
|
||||||
├── index.html # Main HTML entry point
|
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ ├── node_modules/ # Node.js dependencies
|
│ ├── node_modules/
|
||||||
│ ├── public/ # Static public assets
|
│ ├── src/
|
||||||
│ └── src/
|
│ │ ├── pages/
|
||||||
│ ├── assets/ # Static assets
|
│ │ │ ├── Admin/
|
||||||
│ ├── components/
|
│ │ │ │ ├── OrdersPage.jsx
|
||||||
│ │ ├── EmailDialog.jsx # Email dialog component
|
│ │ │ │ ├── SettingsPage.jsx
|
||||||
│ │ ├── Footer.jsx # Footer component
|
│ │ │ │ ├── CustomersPage.jsx
|
||||||
│ │ ├── ImageUploader.jsx # Image upload component
|
│ │ │ │ ├── ProductEditPage.jsx
|
||||||
│ │ ├── Notifications.jsx # Notifications component
|
│ │ │ │ ├── DashboardPage.jsx
|
||||||
│ │ ├── ProductImage.jsx # Product image component
|
│ │ │ │ ├── CategoriesPage.jsx
|
||||||
│ │ └── ProtectedRoute.jsx # Auth route protection
|
│ │ │ │ └── ProductsPage.jsx
|
||||||
│ ├── features/
|
│ │ │ ├── PaymentSuccessPage.jsx
|
||||||
│ │ ├── ui/
|
│ │ │ ├── CheckoutPage.jsx
|
||||||
│ │ │ └── uiSlice.js # UI state management
|
│ │ │ ├── UserOrdersPage.jsx
|
||||||
│ │ ├── cart/
|
│ │ │ ├── PaymentCancelPage.jsx
|
||||||
│ │ │ └── cartSlice.js # Cart state management
|
│ │ │ ├── ProductDetailPage.jsx
|
||||||
│ │ ├── auth/
|
│ │ │ ├── CartPage.jsx
|
||||||
│ │ │ └── authSlice.js # Auth state management
|
│ │ │ ├── ProductsPage.jsx
|
||||||
│ │ └── store/
|
│ │ │ ├── HomePage.jsx
|
||||||
│ │ └── index.js # Redux store configuration
|
│ │ │ ├── VerifyPage.jsx
|
||||||
│ ├── hooks/
|
│ │ │ ├── RegisterPage.jsx
|
||||||
│ │ ├── reduxHooks.js # Redux related hooks
|
│ │ │ ├── NotFoundPage.jsx
|
||||||
│ │ ├── apiHooks.js # API related hooks
|
│ │ │ └── LoginPage.jsx
|
||||||
│ │ └── settingsAdminHooks.js # Admin settings hooks
|
│ │ ├── components/
|
||||||
│ ├── layouts/
|
│ │ │ ├── OrderStatusDialog.jsx
|
||||||
│ │ ├── AdminLayout.jsx # Admin area layout
|
│ │ │ ├── StripePaymentForm.jsx
|
||||||
│ │ ├── MainLayout.jsx # Main site layout
|
│ │ │ ├── EmailDialog.jsx
|
||||||
│ │ └── AuthLayout.jsx # Authentication layout
|
│ │ │ ├── Footer.jsx
|
||||||
│ ├── pages/
|
│ │ │ ├── ImageUploader.jsx
|
||||||
│ │ ├── Admin/
|
│ │ │ ├── ProductImage.jsx
|
||||||
│ │ │ ├── DashboardPage.jsx # Admin dashboard
|
│ │ │ ├── ProtectedRoute.jsx
|
||||||
│ │ │ ├── ProductsPage.jsx # Products management
|
│ │ │ └── Notifications.jsx
|
||||||
│ │ │ ├── ProductEditPage.jsx # Product editing
|
│ │ ├── context/
|
||||||
│ │ │ ├── OrdersPage.jsx # Orders management
|
│ │ │ └── StripeContext.jsx
|
||||||
│ │ │ ├── CategoriesPage.jsx # Categories management
|
│ │ ├── hooks/
|
||||||
│ │ │ ├── CustomersPage.jsx # Customer management
|
│ │ │ ├── apiHooks.js
|
||||||
│ │ │ └── SettingsPage.jsx # Site settings
|
│ │ │ ├── adminHooks.js
|
||||||
│ │ ├── HomePage.jsx # Home page
|
│ │ │ ├── reduxHooks.js
|
||||||
│ │ ├── ProductsPage.jsx # Products listing
|
│ │ │ ├── settingsAdminHooks.js
|
||||||
│ │ ├── ProductDetailPage.jsx # Product details
|
│ │ │ └── categoryAdminHooks.js
|
||||||
│ │ ├── CartPage.jsx # Shopping cart
|
│ │ ├── services/
|
||||||
│ │ ├── CheckoutPage.jsx # Checkout process
|
│ │ │ ├── adminService.js
|
||||||
│ │ ├── LoginPage.jsx # Login page
|
│ │ │ ├── authService.js
|
||||||
│ │ ├── RegisterPage.jsx # Registration page
|
│ │ │ ├── settingsAdminService.js
|
||||||
│ │ ├── VerifyPage.jsx # Email verification
|
│ │ │ ├── cartService.js
|
||||||
│ │ └── NotFoundPage.jsx # 404 page
|
│ │ │ ├── categoryAdminService.js
|
||||||
│ ├── services/
|
│ │ │ ├── imageService.js
|
||||||
│ │ ├── api.js # API client
|
│ │ │ ├── productService.js
|
||||||
│ │ ├── authService.js # Authentication service
|
│ │ │ └── api.js
|
||||||
│ │ ├── cartService.js # Cart management service
|
│ │ ├── utils/
|
||||||
│ │ ├── productService.js # Products service
|
│ │ │ └── imageUtils.js
|
||||||
│ │ ├── settingsAdminService.js # Settings service
|
│ │ ├── layouts/
|
||||||
│ │ ├── adminService.js # Admin service
|
│ │ │ ├── MainLayout.jsx
|
||||||
│ │ ├── categoryAdminService.js # Category service
|
│ │ │ ├── AdminLayout.jsx
|
||||||
│ │ └── imageService.js # Image handling service
|
│ │ │ └── AuthLayout.jsx
|
||||||
│ ├── theme/
|
│ │ ├── theme/
|
||||||
│ │ ├── index.js # Theme configuration
|
│ │ │ ├── index.js
|
||||||
│ │ └── ThemeProvider.jsx # Theme provider component
|
│ │ │ └── ThemeProvider.jsx
|
||||||
│ ├── utils/
|
│ │ ├── features/
|
||||||
│ │ └── imageUtils.js # Image handling utilities
|
│ │ │ ├── ui/
|
||||||
│ ├── App.jsx # Main application component
|
│ │ │ │ └── uiSlice.js
|
||||||
│ ├── main.jsx # Application entry point
|
│ │ │ ├── cart/
|
||||||
│ ├── config.js # Frontend configuration
|
│ │ │ │ └── cartSlice.js
|
||||||
│ └── vite.config.js # Vite bundler configuration
|
│ │ │ ├── auth/
|
||||||
|
│ │ │ │ └── authSlice.js
|
||||||
|
│ │ │ └── store/
|
||||||
|
│ │ │ └── index.js
|
||||||
|
│ │ ├── assets/
|
||||||
|
│ │ ├── App.jsx
|
||||||
|
│ │ ├── config.js
|
||||||
|
│ │ └── main.jsx
|
||||||
|
│ └── public/
|
||||||
|
│ ├── favicon.svg
|
||||||
|
│ ├── package-lock.json
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── .env
|
||||||
|
│ └── setup-frontend.sh
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── node_modules/ # Node.js dependencies
|
|
||||||
│ ├── public/
|
|
||||||
│ │ └── uploads/
|
|
||||||
│ │ └── products/ # Product images storage
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
│ │ │ ├── auth.js # Authentication routes
|
│ │ │ ├── userOrders.js
|
||||||
│ │ │ ├── userAdmin.js # User administration
|
│ │ │ ├── orderAdmin.js
|
||||||
│ │ │ ├── products.js # Product routes
|
│ │ │ ├── stripePayment.js
|
||||||
│ │ │ ├── productAdmin.js # Product administration
|
│ │ │ ├── cart.js
|
||||||
│ │ │ ├── cart.js # Shopping cart routes
|
│ │ │ ├── auth.js
|
||||||
│ │ │ ├── settingsAdmin.js # Settings administration
|
│ │ │ ├── userAdmin.js
|
||||||
│ │ │ ├── images.js # Image handling routes
|
│ │ │ ├── settingsAdmin.js
|
||||||
│ │ │ ├── categoryAdmin.js # Category administration
|
│ │ │ ├── products.js
|
||||||
│ │ │ └── orderAdmin.js # Order administration
|
│ │ │ ├── categoryAdmin.js
|
||||||
│ │ ├── middleware/
|
│ │ │ ├── productAdminImages.js
|
||||||
│ │ │ ├── auth.js # Authentication middleware
|
│ │ │ ├── images.js
|
||||||
│ │ │ ├── adminAuth.js # Admin authentication
|
│ │ │ └── productAdmin.js
|
||||||
│ │ │ └── upload.js # File upload middleware
|
|
||||||
│ │ ├── models/
|
│ │ ├── models/
|
||||||
│ │ │ └── SystemSettings.js # System settings model
|
│ │ │ └── SystemSettings.js
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ │ ├── upload.js
|
||||||
|
│ │ │ ├── auth.js
|
||||||
|
│ │ │ └── adminAuth.js
|
||||||
│ │ ├── db/
|
│ │ ├── db/
|
||||||
│ │ │ └── index.js # Database setup
|
│ │ │ └── index.js
|
||||||
│ │ ├── config.js # Backend configuration
|
│ │ ├── index.js
|
||||||
│ │ └── index.js # Server entry point
|
│ │ └── config.js
|
||||||
│ ├── .env # Backend environment variables
|
│ ├── public/
|
||||||
│ ├── Dockerfile # Backend Dockerfile
|
│ │ └── uploads/
|
||||||
│ └── package.json # Backend dependencies
|
│ │ └── products/
|
||||||
└── db/
|
│ ├── node_modules/
|
||||||
├── init/
|
│ ├── .env
|
||||||
│ ├── 01-schema.sql # Main database schema
|
│ ├── package.json
|
||||||
│ ├── 02-seed.sql # Initial seed data
|
│ ├── Dockerfile
|
||||||
│ ├── 03-api-key.sql # API key setup
|
│ ├── README.md
|
||||||
│ ├── 04-product-images.sql # Product images schema
|
│ └── .gitignore
|
||||||
│ ├── 05-admin-role.sql # Admin role definition
|
├── db/
|
||||||
│ ├── 06-product-categories.sql # Product categories
|
│ ├── init/
|
||||||
│ ├── 07-user-keys.sql # User API keys
|
│ │ ├── 01-schema.sql
|
||||||
│ ├── 08-create-email.sql # Email templates
|
│ │ ├── 02-seed.sql
|
||||||
│ └── 09-system-settings.sql # System settings
|
│ │ ├── 03-api-key.sql
|
||||||
└── test/ # Test database scripts
|
│ │ ├── 04-product-images.sql
|
||||||
|
│ │ ├── 05-admin-role.sql
|
||||||
|
│ │ ├── 06-product-categories.sql
|
||||||
|
│ │ ├── 07-user-keys.sql
|
||||||
|
│ │ ├── 08-create-email.sql
|
||||||
|
│ │ ├── 09-system-settings.sql
|
||||||
|
│ │ ├── 10-payment.sql
|
||||||
|
│ │ └── 11-notifications.sql
|
||||||
|
│ └── .gitignore
|
||||||
|
├── test/
|
||||||
|
├── fileStructure.txt
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .gitignore
|
||||||
|
|
@ -16,16 +16,21 @@ import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Alert
|
Alert,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
FormControl,
|
||||||
|
FormLabel
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||||
import { useAuth, useCart } from '../hooks/reduxHooks';
|
import { useAuth, useCart } from '../hooks/reduxHooks';
|
||||||
import { useCheckout } from '../hooks/apiHooks';
|
import { useCheckout } from '../hooks/apiHooks';
|
||||||
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
|
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
|
||||||
import StripePaymentForm from '../components/StripePaymentForm';
|
import StripePaymentForm from '../components/StripePaymentForm';
|
||||||
|
import apiClient from '../services/api';
|
||||||
|
|
||||||
// Checkout steps
|
// Checkout steps
|
||||||
const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation'];
|
const steps = ['Shipping Address', 'Shipping Method', 'Review Order', 'Payment', 'Confirmation'];
|
||||||
|
|
||||||
const CheckoutPage = () => {
|
const CheckoutPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -37,10 +42,16 @@ const CheckoutPage = () => {
|
||||||
// State for checkout steps
|
// State for checkout steps
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
const [activeStep, setActiveStep] = useState(0);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [orderId, setOrderId] = useState(null);
|
const [orderId, setOrderId] = useState(null);
|
||||||
const [checkoutUrl, setCheckoutUrl] = useState(null);
|
const [checkoutUrl, setCheckoutUrl] = useState(null);
|
||||||
|
|
||||||
|
// State for shipping options
|
||||||
|
const [shippingRates, setShippingRates] = useState([]);
|
||||||
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState(null);
|
||||||
|
const [shippingCost, setShippingCost] = useState(0);
|
||||||
|
|
||||||
// State for form data
|
// State for form data
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: userData?.first_name || '',
|
firstName: userData?.first_name || '',
|
||||||
|
|
@ -75,10 +86,22 @@ const CheckoutPage = () => {
|
||||||
if (!validateShippingForm()) {
|
if (!validateShippingForm()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If valid, fetch shipping rates
|
||||||
|
fetchShippingRates();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If on shipping method step, validate selection
|
||||||
|
if (activeStep === 1) {
|
||||||
|
if (!selectedShippingMethod) {
|
||||||
|
setError('Please select a shipping method');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If on review step, process checkout
|
// If on review step, process checkout
|
||||||
if (activeStep === 1) {
|
if (activeStep === 2) {
|
||||||
handlePlaceOrder();
|
handlePlaceOrder();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +121,7 @@ const CheckoutPage = () => {
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
if (!formData[field]) {
|
if (!formData[field]) {
|
||||||
// In a real app, you'd set specific errors for each field
|
// In a real app, you'd set specific errors for each field
|
||||||
alert(`Please fill in all required fields`);
|
setError(`Please fill in all required fields`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,13 +129,70 @@ const CheckoutPage = () => {
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(formData.email)) {
|
if (!emailRegex.test(formData.email)) {
|
||||||
alert('Please enter a valid email address');
|
setError('Please enter a valid email address');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch shipping rates based on address
|
||||||
|
const fetchShippingRates = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingShipping(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Format shipping address
|
||||||
|
const shippingAddress = {
|
||||||
|
name: `${formData.firstName} ${formData.lastName}`,
|
||||||
|
street: formData.address,
|
||||||
|
city: formData.city,
|
||||||
|
state: formData.province,
|
||||||
|
zip: formData.postalCode,
|
||||||
|
country: formData.country,
|
||||||
|
email: formData.email
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call API to get shipping rates
|
||||||
|
const response = await apiClient.post('/api/cart/shipping-rates', {
|
||||||
|
userId: user,
|
||||||
|
shippingAddress
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.rates && response.data.rates.length > 0) {
|
||||||
|
setShippingRates(response.data.rates);
|
||||||
|
// Default to lowest cost option
|
||||||
|
const lowestCostOption = response.data.rates.reduce(
|
||||||
|
(lowest, current) => current.rate < lowest.rate ? current : lowest,
|
||||||
|
response.data.rates[0]
|
||||||
|
);
|
||||||
|
setSelectedShippingMethod(lowestCostOption);
|
||||||
|
setShippingCost(lowestCostOption.rate);
|
||||||
|
} else {
|
||||||
|
setShippingRates([]);
|
||||||
|
setSelectedShippingMethod(null);
|
||||||
|
setShippingCost(0);
|
||||||
|
setError('No shipping options available for this address');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shipping rates:', error);
|
||||||
|
setError('Failed to retrieve shipping options. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoadingShipping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle shipping method selection
|
||||||
|
const handleShippingMethodChange = (event) => {
|
||||||
|
const selectedMethodId = event.target.value;
|
||||||
|
const method = shippingRates.find(rate => rate.id === selectedMethodId);
|
||||||
|
if (method) {
|
||||||
|
setSelectedShippingMethod(method);
|
||||||
|
setShippingCost(method.rate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle place order
|
// Handle place order
|
||||||
const handlePlaceOrder = async () => {
|
const handlePlaceOrder = async () => {
|
||||||
if (!user || !items || items.length === 0) {
|
if (!user || !items || items.length === 0) {
|
||||||
|
|
@ -133,21 +213,26 @@ const CheckoutPage = () => {
|
||||||
// Call the checkout API to create the order
|
// Call the checkout API to create the order
|
||||||
const orderResponse = await checkout.mutateAsync({
|
const orderResponse = await checkout.mutateAsync({
|
||||||
userId: user,
|
userId: user,
|
||||||
shippingAddress
|
shippingAddress,
|
||||||
|
shippingMethod: selectedShippingMethod
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the order ID for later use
|
// Store the order ID for later use
|
||||||
setOrderId(orderResponse.orderId);
|
setOrderId(orderResponse.orderId);
|
||||||
|
|
||||||
// Proceed to payment step
|
// Proceed to payment step
|
||||||
setActiveStep(2);
|
setActiveStep(3);
|
||||||
|
|
||||||
// Create a Stripe checkout session
|
// Create a Stripe checkout session
|
||||||
const session = await createCheckoutSession(
|
const session = await createCheckoutSession(
|
||||||
orderResponse.cartItems,
|
orderResponse.cartItems,
|
||||||
orderResponse.orderId,
|
orderResponse.orderId,
|
||||||
shippingAddress,
|
shippingAddress,
|
||||||
user
|
user,
|
||||||
|
{
|
||||||
|
shipping_cost: orderResponse.shippingCost || shippingCost,
|
||||||
|
shipping_method: selectedShippingMethod ? selectedShippingMethod.carrier + ' - ' + selectedShippingMethod.service : 'Standard Shipping'
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Redirect to Stripe Checkout
|
// Redirect to Stripe Checkout
|
||||||
|
|
@ -311,6 +396,65 @@ const CheckoutPage = () => {
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Shipping Method
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{isLoadingShipping ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : shippingRates.length > 0 ? (
|
||||||
|
<FormControl component="fieldset" sx={{ width: '100%' }}>
|
||||||
|
<RadioGroup
|
||||||
|
aria-label="shipping-method"
|
||||||
|
name="shipping-method"
|
||||||
|
value={selectedShippingMethod?.id || ''}
|
||||||
|
onChange={handleShippingMethodChange}
|
||||||
|
>
|
||||||
|
{shippingRates.map((rate) => (
|
||||||
|
<Paper
|
||||||
|
key={rate.id}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
p: 2,
|
||||||
|
border: selectedShippingMethod?.id === rate.id ? 2 : 1,
|
||||||
|
borderColor: selectedShippingMethod?.id === rate.id ? 'primary.main' : 'divider'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value={rate.id}
|
||||||
|
control={<Radio />}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
{rate.carrier} - {rate.service}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Estimated delivery: {rate.delivery_days} days
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="primary" sx={{ mt: 1 }}>
|
||||||
|
{rate.rate > 0 ? `$${rate.rate.toFixed(2)}` : 'FREE'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{ width: '100%', m: 0 }}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<Alert severity="warning">
|
||||||
|
No shipping options available for this address. Please check your shipping address or contact support.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
{/* Order summary */}
|
{/* Order summary */}
|
||||||
|
|
@ -332,14 +476,24 @@ const CheckoutPage = () => {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<ListItem sx={{ py: 1, px: 0 }}>
|
<ListItem sx={{ py: 1, px: 0 }}>
|
||||||
<ListItemText primary="Shipping" />
|
<ListItemText primary="Subtotal" />
|
||||||
<Typography variant="body2">Free</Typography>
|
<Typography variant="body2">${total.toFixed(2)}</Typography>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListItem sx={{ py: 1, px: 0 }}>
|
||||||
|
<ListItemText
|
||||||
|
primary="Shipping"
|
||||||
|
secondary={selectedShippingMethod ? `${selectedShippingMethod.carrier} - ${selectedShippingMethod.service}` : 'Standard Shipping'}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{shippingCost > 0 ? `$${shippingCost.toFixed(2)}` : 'Free'}
|
||||||
|
</Typography>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListItem sx={{ py: 1, px: 0 }}>
|
<ListItem sx={{ py: 1, px: 0 }}>
|
||||||
<ListItemText primary="Total" />
|
<ListItemText primary="Total" />
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
${total.toFixed(2)}
|
${(total + shippingCost).toFixed(2)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</List>
|
</List>
|
||||||
|
|
@ -368,7 +522,7 @@ const CheckoutPage = () => {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
{isStripeLoading || isProcessing ? (
|
{isStripeLoading || isProcessing ? (
|
||||||
|
|
@ -391,7 +545,7 @@ const CheckoutPage = () => {
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
||||||
<Alert severity="success" sx={{ mb: 3 }}>
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
|
@ -440,31 +594,39 @@ const CheckoutPage = () => {
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 3 }}>
|
<Paper variant="outlined" sx={{ p: 3 }}>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{getStepContent(activeStep)}
|
{getStepContent(activeStep)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||||
{activeStep !== 0 && activeStep !== 3 && !isProcessing && (
|
{activeStep !== 0 && activeStep !== 3 && activeStep !== 4 && !isProcessing && !isLoadingShipping && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
sx={{ mr: 1 }}
|
sx={{ mr: 1 }}
|
||||||
disabled={checkout.isLoading || isProcessing}
|
disabled={checkout.isLoading || isProcessing || isLoadingShipping}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep !== 2 && activeStep !== 3 ? (
|
{activeStep !== 3 && activeStep !== 4 && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={checkout.isLoading || isProcessing}
|
disabled={checkout.isLoading || isProcessing || isLoadingShipping}
|
||||||
>
|
>
|
||||||
{activeStep === steps.length - 2 ? 'Place Order' : 'Next'}
|
{activeStep === steps.length - 3 ? 'Place Order' : 'Next'}
|
||||||
{(checkout.isLoading || isProcessing) && (
|
{(checkout.isLoading || isProcessing || isLoadingShipping) && (
|
||||||
<CircularProgress size={24} sx={{ ml: 1 }} />
|
<CircularProgress size={24} sx={{ ml: 1 }} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : activeStep === 3 ? (
|
)}
|
||||||
|
|
||||||
|
{activeStep === 4 && (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
|
|
@ -472,7 +634,7 @@ const CheckoutPage = () => {
|
||||||
>
|
>
|
||||||
Return to Home
|
Return to Home
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
241
frontend/src/services/shippingService.js
Normal file
241
frontend/src/services/shippingService.js
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
const axios = require('axios');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling shipping operations with EasyPost API
|
||||||
|
*/
|
||||||
|
const shippingService = {
|
||||||
|
/**
|
||||||
|
* Create a shipment in EasyPost and get available rates
|
||||||
|
* @param {Object} addressFrom - Shipping origin address
|
||||||
|
* @param {Object} addressTo - Customer shipping address
|
||||||
|
* @param {Object} parcelDetails - Package dimensions and weight
|
||||||
|
* @returns {Promise<Array>} Array of available shipping rates
|
||||||
|
*/
|
||||||
|
async getShippingRates(addressFrom, addressTo, parcelDetails) {
|
||||||
|
// If EasyPost is not enabled, return flat rate shipping
|
||||||
|
if (!config.shipping.easypostEnabled || !config.shipping.easypostApiKey) {
|
||||||
|
return this.getFlatRateShipping(parcelDetails.order_total);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Format addresses for EasyPost
|
||||||
|
const fromAddress = this.formatAddress(addressFrom || config.shipping.originAddress);
|
||||||
|
const toAddress = this.formatAddress(addressTo);
|
||||||
|
|
||||||
|
// Format parcel for EasyPost
|
||||||
|
const parcel = this.formatParcel(parcelDetails);
|
||||||
|
|
||||||
|
// Create shipment via EasyPost API
|
||||||
|
const response = await axios.post(
|
||||||
|
'https://api.easypost.com/v2/shipments',
|
||||||
|
{
|
||||||
|
shipment: {
|
||||||
|
from_address: fromAddress,
|
||||||
|
to_address: toAddress,
|
||||||
|
parcel: parcel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
username: config.shipping.easypostApiKey,
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Process and filter rates
|
||||||
|
return this.processShippingRates(response.data.rates, parcelDetails.order_total);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('EasyPost API error:', error.response?.data || error.message);
|
||||||
|
// Fallback to flat rate if API fails
|
||||||
|
return this.getFlatRateShipping(parcelDetails.order_total);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format address for EasyPost API
|
||||||
|
* @param {Object} address - Address details
|
||||||
|
* @returns {Object} Formatted address object
|
||||||
|
*/
|
||||||
|
formatAddress(address) {
|
||||||
|
return {
|
||||||
|
street1: address.street || address.street1,
|
||||||
|
city: address.city,
|
||||||
|
state: address.state || address.province,
|
||||||
|
zip: address.zip || address.postalCode,
|
||||||
|
country: address.country,
|
||||||
|
name: address.name || undefined,
|
||||||
|
company: address.company || undefined,
|
||||||
|
phone: address.phone || undefined,
|
||||||
|
email: address.email || undefined
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format parcel for EasyPost API
|
||||||
|
* @param {Object} parcelDetails - Package dimensions and weight
|
||||||
|
* @returns {Object} Formatted parcel object
|
||||||
|
*/
|
||||||
|
formatParcel(parcelDetails) {
|
||||||
|
const pkg = config.shipping.defaultPackage;
|
||||||
|
|
||||||
|
// Convert weight to ounces if coming from grams
|
||||||
|
const weight = parcelDetails.weight || 500; // Default to 500g if not provided
|
||||||
|
const weightOz = pkg.weightUnit === 'g' ? weight * 0.035274 : weight;
|
||||||
|
|
||||||
|
// Convert dimensions to inches if coming from cm
|
||||||
|
const lengthConversionFactor = pkg.unit === 'cm' ? 0.393701 : 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: (parcelDetails.length || pkg.length) * lengthConversionFactor,
|
||||||
|
width: (parcelDetails.width || pkg.width) * lengthConversionFactor,
|
||||||
|
height: (parcelDetails.height || pkg.height) * lengthConversionFactor,
|
||||||
|
weight: weightOz,
|
||||||
|
predefined_package: parcelDetails.predefined_package || null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process and filter shipping rates from EasyPost
|
||||||
|
* @param {Array} rates - EasyPost shipping rates
|
||||||
|
* @param {number} orderTotal - Order total amount
|
||||||
|
* @returns {Array} Processed shipping rates
|
||||||
|
*/
|
||||||
|
processShippingRates(rates, orderTotal) {
|
||||||
|
if (!rates || !Array.isArray(rates)) {
|
||||||
|
return this.getFlatRateShipping(orderTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by allowed carriers
|
||||||
|
let filteredRates = rates.filter(rate =>
|
||||||
|
config.shipping.carriersAllowed.some(carrier =>
|
||||||
|
rate.carrier.toUpperCase().includes(carrier.toUpperCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filteredRates.length === 0) {
|
||||||
|
return this.getFlatRateShipping(orderTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format rates to standardized format
|
||||||
|
const formattedRates = filteredRates.map(rate => ({
|
||||||
|
id: rate.id,
|
||||||
|
carrier: rate.carrier,
|
||||||
|
service: rate.service,
|
||||||
|
rate: parseFloat(rate.rate),
|
||||||
|
currency: rate.currency,
|
||||||
|
delivery_days: rate.delivery_days || 'Unknown',
|
||||||
|
delivery_date: rate.delivery_date || null,
|
||||||
|
delivery_time: rate.est_delivery_time || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if free shipping applies
|
||||||
|
if (orderTotal >= config.shipping.freeThreshold) {
|
||||||
|
formattedRates.push({
|
||||||
|
id: 'free-shipping',
|
||||||
|
carrier: 'FREE',
|
||||||
|
service: 'Standard Shipping',
|
||||||
|
rate: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
delivery_days: '5-7',
|
||||||
|
delivery_date: null,
|
||||||
|
delivery_time: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedRates;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get flat rate shipping as fallback
|
||||||
|
* @param {number} orderTotal - Order total amount
|
||||||
|
* @returns {Array} Flat rate shipping options
|
||||||
|
*/
|
||||||
|
getFlatRateShipping(orderTotal) {
|
||||||
|
const shippingOptions = [{
|
||||||
|
id: 'flat-rate',
|
||||||
|
carrier: 'Standard',
|
||||||
|
service: 'Flat Rate Shipping',
|
||||||
|
rate: config.shipping.flatRate,
|
||||||
|
currency: 'USD',
|
||||||
|
delivery_days: '5-7',
|
||||||
|
delivery_date: null,
|
||||||
|
delivery_time: null
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Add free shipping if order qualifies
|
||||||
|
if (orderTotal >= config.shipping.freeThreshold) {
|
||||||
|
shippingOptions.push({
|
||||||
|
id: 'free-shipping',
|
||||||
|
carrier: 'FREE',
|
||||||
|
service: 'Standard Shipping',
|
||||||
|
rate: 0,
|
||||||
|
currency: 'USD',
|
||||||
|
delivery_days: '5-7',
|
||||||
|
delivery_date: null,
|
||||||
|
delivery_time: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return shippingOptions;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse shipping address from string format
|
||||||
|
* @param {string} addressString - Shipping address as string
|
||||||
|
* @returns {Object} Parsed address object
|
||||||
|
*/
|
||||||
|
parseAddressString(addressString) {
|
||||||
|
const lines = addressString.trim().split('\n').map(line => line.trim());
|
||||||
|
|
||||||
|
// Try to intelligently parse the address components
|
||||||
|
// This is a simplified version - might need enhancement for edge cases
|
||||||
|
const parsedAddress = {
|
||||||
|
name: lines[0] || '',
|
||||||
|
street: lines[1] || '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip: '',
|
||||||
|
country: lines[lines.length - 1] || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to parse city, state, zip from line 2
|
||||||
|
if (lines[2]) {
|
||||||
|
const cityStateZip = lines[2].split(',');
|
||||||
|
if (cityStateZip.length >= 2) {
|
||||||
|
parsedAddress.city = cityStateZip[0].trim();
|
||||||
|
|
||||||
|
// Split state and zip
|
||||||
|
const stateZip = cityStateZip[1].trim().split(' ');
|
||||||
|
if (stateZip.length >= 2) {
|
||||||
|
parsedAddress.state = stateZip[0].trim();
|
||||||
|
parsedAddress.zip = stateZip.slice(1).join(' ').trim();
|
||||||
|
} else {
|
||||||
|
parsedAddress.state = stateZip[0].trim();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedAddress.city = lines[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedAddress;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total shipping weight from cart items
|
||||||
|
* @param {Array} items - Cart items
|
||||||
|
* @returns {number} Total weight in grams
|
||||||
|
*/
|
||||||
|
calculateTotalWeight(items) {
|
||||||
|
return items.reduce((total, item) => {
|
||||||
|
const itemWeight = item.weight_grams || 100;
|
||||||
|
return total + (itemWeight * item.quantity);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = shippingService;
|
||||||
Loading…
Reference in a new issue