shippinh integration

This commit is contained in:
2ManyProjects 2025-04-27 10:28:18 -05:00
parent ceb26c6524
commit aa2a97bbad
9 changed files with 1017 additions and 153 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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
}); });

View 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;
}

View file

@ -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;

View 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;

View file

@ -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

View file

@ -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>

View 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;