From aa2a97bbadf40e983f91e755e0098ed87d011f36 Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Sun, 27 Apr 2025 10:28:18 -0500 Subject: [PATCH] shippinh integration --- backend/src/config.js | 68 +++++++ backend/src/index.js | 9 +- backend/src/routes/cart.js | 205 +++++++++++++++++-- backend/src/routes/shipping.js | 179 +++++++++++++++++ db/init/09-system-settings.sql | 15 +- db/init/12-shipping-orders.sql | 10 + fileStructure.txt | 239 +++++++++++----------- frontend/src/pages/CheckoutPage.jsx | 204 +++++++++++++++++-- frontend/src/services/shippingService.js | 241 +++++++++++++++++++++++ 9 files changed, 1017 insertions(+), 153 deletions(-) create mode 100644 backend/src/routes/shipping.js create mode 100644 db/init/12-shipping-orders.sql create mode 100644 frontend/src/services/shippingService.js 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