diff --git a/backend/src/config.js b/backend/src/config.js
index b675058..f65fcbd 100644
--- a/backend/src/config.js
+++ b/backend/src/config.js
@@ -35,6 +35,30 @@ const config = {
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: {
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;
}
+ // 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
const siteSettings = settings.filter(s => s.category === 'site');
if (siteSettings.length > 0) {
diff --git a/backend/src/index.js b/backend/src/index.js
index a928e18..17cabe8 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -17,10 +17,12 @@ const productRoutes = require('./routes/products');
const authRoutes = require('./routes/auth');
const cartRoutes = require('./routes/cart');
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 ordersAdminRoutes = require('./routes/orderAdmin');
const userOrdersRoutes = require('./routes/userOrders');
+const shippingRoutes = require('./routes/shipping');
+
// Create Express app
const app = express();
const port = config.port || 4000;
@@ -240,8 +242,6 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
}
});
-
-
// Use routes
app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(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/cart', cartRoutes(pool, query, authMiddleware(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
app.use((err, req, res, next) => {
console.error(err.stack);
diff --git a/backend/src/routes/cart.js b/backend/src/routes/cart.js
index 552bffb..705e8a1 100644
--- a/backend/src/routes/cart.js
+++ b/backend/src/routes/cart.js
@@ -1,6 +1,8 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const router = express.Router();
+const shippingService = require('../services/shippingService');
+const config = require('../config');
module.exports = (pool, query, authMiddleware) => {
@@ -37,6 +39,7 @@ module.exports = (pool, query, authMiddleware) => {
`SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price,
p.category_id, pc.name AS category_name,
+ p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
@@ -53,7 +56,7 @@ module.exports = (pool, query, authMiddleware) => {
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
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]
);
@@ -72,16 +75,28 @@ module.exports = (pool, query, authMiddleware) => {
});
// Calculate total
- const total = processedItems.reduce((sum, item) => {
+ const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
+ // Initialize shipping
+ const shipping = {
+ rates: []
+ };
+
+ // Calculate basic flat rate shipping
+ if (config.shipping.enabled) {
+ shipping.rates = await shippingService.getFlatRateShipping(subtotal);
+ }
+
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
- total
+ subtotal,
+ shipping,
+ total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
});
} catch (error) {
next(error);
@@ -179,6 +194,7 @@ module.exports = (pool, query, authMiddleware) => {
`SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name,
+ p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
@@ -195,7 +211,7 @@ module.exports = (pool, query, authMiddleware) => {
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
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]
);
@@ -213,17 +229,29 @@ module.exports = (pool, query, authMiddleware) => {
};
});
- // Calculate total
- const total = processedItems.reduce((sum, item) => {
+ // Calculate subtotal
+ const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
+ // Initialize shipping
+ const shipping = {
+ rates: []
+ };
+
+ // Calculate basic flat rate shipping
+ if (config.shipping.enabled) {
+ shipping.rates = await shippingService.getFlatRateShipping(subtotal);
+ }
+
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
- total
+ subtotal,
+ shipping,
+ total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
});
} catch (error) {
next(error);
@@ -299,6 +327,7 @@ module.exports = (pool, query, authMiddleware) => {
`SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name,
+ p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
(
SELECT json_agg(
json_build_object(
@@ -315,7 +344,7 @@ module.exports = (pool, query, authMiddleware) => {
JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id
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]
);
@@ -333,17 +362,29 @@ module.exports = (pool, query, authMiddleware) => {
};
});
- // Calculate total
- const total = processedItems.reduce((sum, item) => {
+ // Calculate subtotal
+ const subtotal = processedItems.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
+ // Initialize shipping
+ const shipping = {
+ rates: []
+ };
+
+ // Calculate basic flat rate shipping
+ if (config.shipping.enabled) {
+ shipping.rates = await shippingService.getFlatRateShipping(subtotal);
+ }
+
res.json({
id: cartId,
userId,
items: processedItems,
itemCount: processedItems.length,
- total
+ subtotal,
+ shipping,
+ total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
});
} catch (error) {
next(error);
@@ -386,6 +427,10 @@ module.exports = (pool, query, authMiddleware) => {
userId,
items: [],
itemCount: 0,
+ subtotal: 0,
+ shipping: {
+ rates: []
+ },
total: 0
});
} 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 {
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) {
return res.status(403).json({
error: true,
@@ -421,7 +557,7 @@ module.exports = (pool, query, authMiddleware) => {
// Get cart items
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(
'path', pi.image_path,
@@ -444,11 +580,28 @@ module.exports = (pool, query, authMiddleware) => {
});
}
- // Calculate total
- const total = cartItemsResult.rows.reduce((sum, item) => {
+ // Calculate subtotal
+ const subtotal = cartItemsResult.rows.reduce((sum, item) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 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
const client = await pool.connect();
@@ -458,8 +611,8 @@ module.exports = (pool, query, authMiddleware) => {
// Create order
const orderId = uuidv4();
await client.query(
- 'INSERT INTO orders (id, user_id, status, total_amount, shipping_address, payment_completed) VALUES ($1, $2, $3, $4, $5, $6)',
- [orderId, userId, 'pending', total, shippingAddress, false]
+ '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, shippingCost]
);
// 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');
// Send back cart items for Stripe checkout
@@ -478,6 +647,8 @@ module.exports = (pool, query, authMiddleware) => {
message: 'Order created successfully, ready for payment',
orderId,
cartItems: cartItemsResult.rows,
+ subtotal,
+ shippingCost,
total
});
diff --git a/backend/src/routes/shipping.js b/backend/src/routes/shipping.js
new file mode 100644
index 0000000..271509c
--- /dev/null
+++ b/backend/src/routes/shipping.js
@@ -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;
+ }
+
\ No newline at end of file
diff --git a/db/init/09-system-settings.sql b/db/init/09-system-settings.sql
index fe6e2cc..2db5671 100644
--- a/db/init/09-system-settings.sql
+++ b/db/init/09-system-settings.sql
@@ -40,5 +40,18 @@ VALUES
-- Shipping Settings
('shipping_flat_rate', '10.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;
\ No newline at end of file
diff --git a/db/init/12-shipping-orders.sql b/db/init/12-shipping-orders.sql
new file mode 100644
index 0000000..ff83812
--- /dev/null
+++ b/db/init/12-shipping-orders.sql
@@ -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;
\ No newline at end of file
diff --git a/fileStructure.txt b/fileStructure.txt
index d058992..4c3de10 100644
--- a/fileStructure.txt
+++ b/fileStructure.txt
@@ -1,117 +1,136 @@
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/
-│ ├── node_modules/ # Node.js dependencies
-│ ├── public/ # Static public assets
-│ └── src/
-│ ├── assets/ # Static assets
-│ ├── components/
-│ │ ├── EmailDialog.jsx # Email dialog component
-│ │ ├── Footer.jsx # Footer component
-│ │ ├── ImageUploader.jsx # Image upload component
-│ │ ├── Notifications.jsx # Notifications component
-│ │ ├── ProductImage.jsx # Product image component
-│ │ └── ProtectedRoute.jsx # Auth route protection
-│ ├── features/
-│ │ ├── ui/
-│ │ │ └── uiSlice.js # UI state management
-│ │ ├── cart/
-│ │ │ └── cartSlice.js # Cart state management
-│ │ ├── auth/
-│ │ │ └── authSlice.js # Auth state management
-│ │ └── store/
-│ │ └── index.js # Redux store configuration
-│ ├── hooks/
-│ │ ├── reduxHooks.js # Redux related hooks
-│ │ ├── apiHooks.js # API related hooks
-│ │ └── settingsAdminHooks.js # Admin settings hooks
-│ ├── layouts/
-│ │ ├── AdminLayout.jsx # Admin area layout
-│ │ ├── MainLayout.jsx # Main site layout
-│ │ └── AuthLayout.jsx # Authentication layout
-│ ├── pages/
-│ │ ├── Admin/
-│ │ │ ├── DashboardPage.jsx # Admin dashboard
-│ │ │ ├── ProductsPage.jsx # Products management
-│ │ │ ├── ProductEditPage.jsx # Product editing
-│ │ │ ├── OrdersPage.jsx # Orders management
-│ │ │ ├── CategoriesPage.jsx # Categories management
-│ │ │ ├── CustomersPage.jsx # Customer management
-│ │ │ └── SettingsPage.jsx # Site settings
-│ │ ├── HomePage.jsx # Home page
-│ │ ├── ProductsPage.jsx # Products listing
-│ │ ├── ProductDetailPage.jsx # Product details
-│ │ ├── CartPage.jsx # Shopping cart
-│ │ ├── CheckoutPage.jsx # Checkout process
-│ │ ├── LoginPage.jsx # Login page
-│ │ ├── RegisterPage.jsx # Registration page
-│ │ ├── VerifyPage.jsx # Email verification
-│ │ └── NotFoundPage.jsx # 404 page
-│ ├── services/
-│ │ ├── api.js # API client
-│ │ ├── authService.js # Authentication service
-│ │ ├── cartService.js # Cart management service
-│ │ ├── productService.js # Products service
-│ │ ├── settingsAdminService.js # Settings service
-│ │ ├── adminService.js # Admin service
-│ │ ├── categoryAdminService.js # Category service
-│ │ └── imageService.js # Image handling service
-│ ├── theme/
-│ │ ├── index.js # Theme configuration
-│ │ └── ThemeProvider.jsx # Theme provider component
-│ ├── utils/
-│ │ └── imageUtils.js # Image handling utilities
-│ ├── App.jsx # Main application component
-│ ├── main.jsx # Application entry point
-│ ├── config.js # Frontend configuration
-│ └── vite.config.js # Vite bundler configuration
+│ ├── node_modules/
+│ ├── src/
+│ │ ├── pages/
+│ │ │ ├── Admin/
+│ │ │ │ ├── OrdersPage.jsx
+│ │ │ │ ├── SettingsPage.jsx
+│ │ │ │ ├── CustomersPage.jsx
+│ │ │ │ ├── ProductEditPage.jsx
+│ │ │ │ ├── DashboardPage.jsx
+│ │ │ │ ├── CategoriesPage.jsx
+│ │ │ │ └── ProductsPage.jsx
+│ │ │ ├── PaymentSuccessPage.jsx
+│ │ │ ├── CheckoutPage.jsx
+│ │ │ ├── UserOrdersPage.jsx
+│ │ │ ├── PaymentCancelPage.jsx
+│ │ │ ├── ProductDetailPage.jsx
+│ │ │ ├── CartPage.jsx
+│ │ │ ├── ProductsPage.jsx
+│ │ │ ├── HomePage.jsx
+│ │ │ ├── VerifyPage.jsx
+│ │ │ ├── RegisterPage.jsx
+│ │ │ ├── NotFoundPage.jsx
+│ │ │ └── LoginPage.jsx
+│ │ ├── components/
+│ │ │ ├── OrderStatusDialog.jsx
+│ │ │ ├── StripePaymentForm.jsx
+│ │ │ ├── EmailDialog.jsx
+│ │ │ ├── Footer.jsx
+│ │ │ ├── ImageUploader.jsx
+│ │ │ ├── ProductImage.jsx
+│ │ │ ├── ProtectedRoute.jsx
+│ │ │ └── Notifications.jsx
+│ │ ├── context/
+│ │ │ └── StripeContext.jsx
+│ │ ├── hooks/
+│ │ │ ├── apiHooks.js
+│ │ │ ├── adminHooks.js
+│ │ │ ├── reduxHooks.js
+│ │ │ ├── settingsAdminHooks.js
+│ │ │ └── categoryAdminHooks.js
+│ │ ├── services/
+│ │ │ ├── adminService.js
+│ │ │ ├── authService.js
+│ │ │ ├── settingsAdminService.js
+│ │ │ ├── cartService.js
+│ │ │ ├── categoryAdminService.js
+│ │ │ ├── imageService.js
+│ │ │ ├── productService.js
+│ │ │ └── api.js
+│ │ ├── utils/
+│ │ │ └── imageUtils.js
+│ │ ├── layouts/
+│ │ │ ├── MainLayout.jsx
+│ │ │ ├── AdminLayout.jsx
+│ │ │ └── AuthLayout.jsx
+│ │ ├── theme/
+│ │ │ ├── index.js
+│ │ │ └── ThemeProvider.jsx
+│ │ ├── features/
+│ │ │ ├── ui/
+│ │ │ │ └── uiSlice.js
+│ │ │ ├── cart/
+│ │ │ │ └── cartSlice.js
+│ │ │ ├── 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/
-│ ├── node_modules/ # Node.js dependencies
-│ ├── public/
-│ │ └── uploads/
-│ │ └── products/ # Product images storage
│ ├── src/
│ │ ├── routes/
-│ │ │ ├── auth.js # Authentication routes
-│ │ │ ├── userAdmin.js # User administration
-│ │ │ ├── products.js # Product routes
-│ │ │ ├── productAdmin.js # Product administration
-│ │ │ ├── cart.js # Shopping cart routes
-│ │ │ ├── settingsAdmin.js # Settings administration
-│ │ │ ├── images.js # Image handling routes
-│ │ │ ├── categoryAdmin.js # Category administration
-│ │ │ └── orderAdmin.js # Order administration
-│ │ ├── middleware/
-│ │ │ ├── auth.js # Authentication middleware
-│ │ │ ├── adminAuth.js # Admin authentication
-│ │ │ └── upload.js # File upload middleware
+│ │ │ ├── userOrders.js
+│ │ │ ├── orderAdmin.js
+│ │ │ ├── stripePayment.js
+│ │ │ ├── cart.js
+│ │ │ ├── auth.js
+│ │ │ ├── userAdmin.js
+│ │ │ ├── settingsAdmin.js
+│ │ │ ├── products.js
+│ │ │ ├── categoryAdmin.js
+│ │ │ ├── productAdminImages.js
+│ │ │ ├── images.js
+│ │ │ └── productAdmin.js
│ │ ├── models/
-│ │ │ └── SystemSettings.js # System settings model
+│ │ │ └── SystemSettings.js
+│ │ ├── middleware/
+│ │ │ ├── upload.js
+│ │ │ ├── auth.js
+│ │ │ └── adminAuth.js
│ │ ├── db/
-│ │ │ └── index.js # Database setup
-│ │ ├── config.js # Backend configuration
-│ │ └── index.js # Server entry point
-│ ├── .env # Backend environment variables
-│ ├── Dockerfile # Backend Dockerfile
-│ └── package.json # Backend dependencies
-└── db/
- ├── init/
- │ ├── 01-schema.sql # Main database schema
- │ ├── 02-seed.sql # Initial seed data
- │ ├── 03-api-key.sql # API key setup
- │ ├── 04-product-images.sql # Product images schema
- │ ├── 05-admin-role.sql # Admin role definition
- │ ├── 06-product-categories.sql # Product categories
- │ ├── 07-user-keys.sql # User API keys
- │ ├── 08-create-email.sql # Email templates
- │ └── 09-system-settings.sql # System settings
- └── test/ # Test database scripts
\ No newline at end of file
+│ │ │ └── index.js
+│ │ ├── index.js
+│ │ └── config.js
+│ ├── public/
+│ │ └── uploads/
+│ │ └── products/
+│ ├── node_modules/
+│ ├── .env
+│ ├── package.json
+│ ├── Dockerfile
+│ ├── README.md
+│ └── .gitignore
+├── db/
+│ ├── init/
+│ │ ├── 01-schema.sql
+│ │ ├── 02-seed.sql
+│ │ ├── 03-api-key.sql
+│ │ ├── 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
\ No newline at end of file
diff --git a/frontend/src/pages/CheckoutPage.jsx b/frontend/src/pages/CheckoutPage.jsx
index a4235ad..71e2c92 100644
--- a/frontend/src/pages/CheckoutPage.jsx
+++ b/frontend/src/pages/CheckoutPage.jsx
@@ -16,16 +16,21 @@ import {
List,
ListItem,
ListItemText,
- Alert
+ Alert,
+ Radio,
+ RadioGroup,
+ FormControl,
+ FormLabel
} from '@mui/material';
import { useNavigate, Link as RouterLink } from 'react-router-dom';
import { useAuth, useCart } from '../hooks/reduxHooks';
import { useCheckout } from '../hooks/apiHooks';
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
import StripePaymentForm from '../components/StripePaymentForm';
+import apiClient from '../services/api';
// Checkout steps
-const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation'];
+const steps = ['Shipping Address', 'Shipping Method', 'Review Order', 'Payment', 'Confirmation'];
const CheckoutPage = () => {
const navigate = useNavigate();
@@ -37,10 +42,16 @@ const CheckoutPage = () => {
// State for checkout steps
const [activeStep, setActiveStep] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
+ const [isLoadingShipping, setIsLoadingShipping] = useState(false);
const [error, setError] = useState(null);
const [orderId, setOrderId] = 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
const [formData, setFormData] = useState({
firstName: userData?.first_name || '',
@@ -75,10 +86,22 @@ const CheckoutPage = () => {
if (!validateShippingForm()) {
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 (activeStep === 1) {
+ if (activeStep === 2) {
handlePlaceOrder();
return;
}
@@ -98,7 +121,7 @@ const CheckoutPage = () => {
for (const field of requiredFields) {
if (!formData[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;
}
}
@@ -106,13 +129,70 @@ const CheckoutPage = () => {
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
- alert('Please enter a valid email address');
+ setError('Please enter a valid email address');
return false;
}
+ setError(null);
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
const handlePlaceOrder = async () => {
if (!user || !items || items.length === 0) {
@@ -133,21 +213,26 @@ const CheckoutPage = () => {
// Call the checkout API to create the order
const orderResponse = await checkout.mutateAsync({
userId: user,
- shippingAddress
+ shippingAddress,
+ shippingMethod: selectedShippingMethod
});
// Store the order ID for later use
setOrderId(orderResponse.orderId);
// Proceed to payment step
- setActiveStep(2);
+ setActiveStep(3);
// Create a Stripe checkout session
const session = await createCheckoutSession(
orderResponse.cartItems,
orderResponse.orderId,
shippingAddress,
- user
+ user,
+ {
+ shipping_cost: orderResponse.shippingCost || shippingCost,
+ shipping_method: selectedShippingMethod ? selectedShippingMethod.carrier + ' - ' + selectedShippingMethod.service : 'Standard Shipping'
+ }
);
// Redirect to Stripe Checkout
@@ -311,6 +396,65 @@ const CheckoutPage = () => {
);
case 1:
+ return (
+
+
+ Shipping Method
+
+
+ {isLoadingShipping ? (
+
+
+
+ ) : shippingRates.length > 0 ? (
+
+
+ {shippingRates.map((rate) => (
+
+ }
+ label={
+
+
+ {rate.carrier} - {rate.service}
+
+
+ Estimated delivery: {rate.delivery_days} days
+
+
+ {rate.rate > 0 ? `$${rate.rate.toFixed(2)}` : 'FREE'}
+
+
+ }
+ sx={{ width: '100%', m: 0 }}
+ />
+
+ ))}
+
+
+ ) : (
+
+ No shipping options available for this address. Please check your shipping address or contact support.
+
+ )}
+
+ );
+ case 2:
return (
{/* Order summary */}
@@ -332,14 +476,24 @@ const CheckoutPage = () => {
))}
-
- Free
+
+ ${total.toFixed(2)}
+
+
+
+
+
+ {shippingCost > 0 ? `$${shippingCost.toFixed(2)}` : 'Free'}
+
- ${total.toFixed(2)}
+ ${(total + shippingCost).toFixed(2)}
@@ -368,7 +522,7 @@ const CheckoutPage = () => {
);
- case 2:
+ case 3:
return (
{isStripeLoading || isProcessing ? (
@@ -391,7 +545,7 @@ const CheckoutPage = () => {
)}
);
- case 3:
+ case 4:
return (
@@ -440,31 +594,39 @@ const CheckoutPage = () => {
+ {error && (
+
+ {error}
+
+ )}
+
{getStepContent(activeStep)}
- {activeStep !== 0 && activeStep !== 3 && !isProcessing && (
+ {activeStep !== 0 && activeStep !== 3 && activeStep !== 4 && !isProcessing && !isLoadingShipping && (
)}
- {activeStep !== 2 && activeStep !== 3 ? (
+ {activeStep !== 3 && activeStep !== 4 && (
- ) : activeStep === 3 ? (
+ )}
+
+ {activeStep === 4 && (
- ) : null}
+ )}
diff --git a/frontend/src/services/shippingService.js b/frontend/src/services/shippingService.js
new file mode 100644
index 0000000..2dd3e85
--- /dev/null
+++ b/frontend/src/services/shippingService.js
@@ -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 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;
\ No newline at end of file