From c0b9c6e4f10ec4c3db5ca767c28d006df631e360 Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Sat, 26 Apr 2025 19:25:36 -0500 Subject: [PATCH] Stripe integration --- backend/package.json | 1 + backend/src/config.js | 56 +++++ backend/src/index.js | 22 +- backend/src/routes/cart.js | 122 +++++++-- backend/src/routes/stripePayment.js | 198 +++++++++++++++ db/init/09-system-settings.sql | 2 - db/init/10-payment.sql | 22 ++ fileStructure.txt | 213 +++++++++------- frontend/package-lock.json | 20 ++ frontend/package.json | 2 + frontend/src/App.jsx | 111 ++++---- frontend/src/components/StripePaymentForm.jsx | 98 ++++++++ frontend/src/context/StripeContext.jsx | 151 +++++++++++ frontend/src/pages/Admin/SettingsPage.jsx | 237 ++++++++++++------ frontend/src/pages/CheckoutPage.jsx | 113 ++++++--- frontend/src/pages/PaymentCancelPage.jsx | 53 ++++ frontend/src/pages/PaymentSuccessPage.jsx | 147 +++++++++++ 17 files changed, 1299 insertions(+), 269 deletions(-) create mode 100644 backend/src/routes/stripePayment.js create mode 100644 db/init/10-payment.sql create mode 100644 frontend/src/components/StripePaymentForm.jsx create mode 100644 frontend/src/context/StripeContext.jsx create mode 100644 frontend/src/pages/PaymentCancelPage.jsx create mode 100644 frontend/src/pages/PaymentSuccessPage.jsx diff --git a/backend/package.json b/backend/package.json index 42bc4d2..a554651 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "express": "^4.18.2", "morgan": "^1.10.0", "multer": "^1.4.5-lts.2", + "stripe": "^12.0.0", "nodemailer": "^6.9.1", "pg": "^8.10.0", "pg-hstore": "^2.3.4", diff --git a/backend/src/config.js b/backend/src/config.js index 80d0efd..b675058 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -27,6 +27,14 @@ const config = { reply: process.env.EMAIL_REPLY || 'noreply@2many.ca' }, + // Payment configuration + payment: { + stripeEnabled: process.env.STRIPE_ENABLED === 'true', + stripePublicKey: process.env.STRIPE_PUBLIC_KEY || '', + stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '' + }, + // Site configuration (domain and protocol based on environment) site: { domain: process.env.ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000', @@ -35,4 +43,52 @@ const config = { } }; +// This function will be called after loading settings from DB +// to override config values with DB values +config.updateFromDatabase = (settings) => { + if (!settings) return; + + // Update email settings if they exist in DB + const emailSettings = settings.filter(s => s.category === 'email'); + if (emailSettings.length > 0) { + const smtpHost = emailSettings.find(s => s.key === 'smtp_host'); + const smtpPort = emailSettings.find(s => s.key === 'smtp_port'); + const smtpUser = emailSettings.find(s => s.key === 'smtp_user'); + const smtpPass = emailSettings.find(s => s.key === 'smtp_password'); + const smtpReply = emailSettings.find(s => s.key === 'smtp_from_email'); + + if (smtpHost && smtpHost.value) config.email.host = smtpHost.value; + if (smtpPort && smtpPort.value) config.email.port = parseInt(smtpPort.value, 10); + if (smtpUser && smtpUser.value) config.email.user = smtpUser.value; + if (smtpPass && smtpPass.value) config.email.pass = smtpPass.value; + if (smtpReply && smtpReply.value) config.email.reply = smtpReply.value; + } + + // Update payment settings if they exist in DB + const paymentSettings = settings.filter(s => s.category === 'payment'); + if (paymentSettings.length > 0) { + const stripeEnabled = paymentSettings.find(s => s.key === 'stripe_enabled'); + const stripePublic = paymentSettings.find(s => s.key === 'stripe_public_key'); + const stripeSecret = paymentSettings.find(s => s.key === 'stripe_secret_key'); + const stripeWebhook = paymentSettings.find(s => s.key === 'stripe_webhook_secret'); + + if (stripeEnabled && stripeEnabled.value) config.payment.stripeEnabled = stripeEnabled.value === 'true'; + if (stripePublic && stripePublic.value) config.payment.stripePublicKey = stripePublic.value; + if (stripeSecret && stripeSecret.value) config.payment.stripeSecretKey = stripeSecret.value; + if (stripeWebhook && stripeWebhook.value) config.payment.stripeWebhookSecret = stripeWebhook.value; + } + + // Update site settings if they exist in DB + const siteSettings = settings.filter(s => s.category === 'site'); + if (siteSettings.length > 0) { + const siteDomain = siteSettings.find(s => s.key === 'site_domain'); + const siteProtocol = siteSettings.find(s => s.key === 'site_protocol'); + const siteApiDomain = siteSettings.find(s => s.key === 'site_api_domain'); + + if (siteDomain && siteDomain.value) config.site.domain = siteDomain.value; + if (siteProtocol && siteProtocol.value) config.site.protocol = siteProtocol.value; + if (siteApiDomain && siteApiDomain.value) config.site.apiDomain = siteApiDomain.value; + } +}; + module.exports = config; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 5e61b44..9346889 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -8,9 +8,11 @@ const { query, pool } = require('./db') const authMiddleware = require('./middleware/auth'); const adminAuthMiddleware = require('./middleware/adminAuth'); const settingsAdminRoutes = require('./routes/settingsAdmin'); +const SystemSettings = require('./models/SystemSettings'); const fs = require('fs'); // routes +const stripePaymentRoutes = require('./routes/stripePayment'); const productRoutes = require('./routes/products'); const authRoutes = require('./routes/auth'); const cartRoutes = require('./routes/cart'); @@ -76,9 +78,24 @@ const upload = multer({ }); pool.connect() - .then(() => console.log('Connected to PostgreSQL database')) + .then(async () => { + console.log('Connected to PostgreSQL database'); + + // Load settings from database + try { + const settings = await SystemSettings.getAllSettings(pool, query); + if (settings) { + // Update config with database settings + config.updateFromDatabase(settings); + console.log('Loaded settings from database'); + } + } catch (error) { + console.error('Error loading settings from database:', error); + } + }) .catch(err => console.error('Database connection error:', err)); + // Handle SSL proxy headers app.use((req, res, next) => { // Trust X-Forwarded-Proto header from Cloudflare @@ -220,7 +237,10 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re } }); + + // Use routes +app.use('/api/payment', stripePaymentRoutes(pool, query, authMiddleware(pool, query))); app.use('/api/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query))); app.use('/api/products', productRoutes(pool, query)); app.use('/api/auth', authRoutes(pool, query)); diff --git a/backend/src/routes/cart.js b/backend/src/routes/cart.js index 49dfafb..552bffb 100644 --- a/backend/src/routes/cart.js +++ b/backend/src/routes/cart.js @@ -393,16 +393,17 @@ module.exports = (pool, query, authMiddleware) => { } }); - // Checkout (create order from cart) router.post('/checkout', 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 checkout your own cart' }); } + // Get cart const cartResult = await query( 'SELECT * FROM carts WHERE user_id = $1', @@ -420,7 +421,16 @@ module.exports = (pool, query, authMiddleware) => { // Get cart items const cartItemsResult = await query( - `SELECT ci.*, p.price + `SELECT ci.*, p.price, p.name, p.description, + ( + SELECT json_build_object( + 'path', pi.image_path, + 'isPrimary', pi.is_primary + ) + FROM product_images pi + WHERE pi.product_id = p.id AND pi.is_primary = true + LIMIT 1 + ) AS primary_image FROM cart_items ci JOIN products p ON ci.product_id = p.id WHERE ci.cart_id = $1`, @@ -448,8 +458,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) VALUES ($1, $2, $3, $4, $5)', - [orderId, userId, 'pending', total, shippingAddress] + '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] ); // Create order items @@ -458,25 +468,103 @@ module.exports = (pool, query, authMiddleware) => { 'INSERT INTO order_items (id, order_id, product_id, quantity, price_at_purchase) VALUES ($1, $2, $3, $4, $5)', [uuidv4(), orderId, item.product_id, item.quantity, item.price] ); - - // Update product stock - await client.query( - 'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2', - [item.quantity, item.product_id] - ); } - // Clear cart - await client.query( - 'DELETE FROM cart_items WHERE cart_id = $1', - [cartId] - ); - await client.query('COMMIT'); + // Send back cart items for Stripe checkout res.status(201).json({ success: true, - message: 'Order created successfully', + message: 'Order created successfully, ready for payment', + orderId, + cartItems: cartItemsResult.rows, + total + }); + + // Note: We don't clear the cart here now - we'll do that after successful payment + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } catch (error) { + next(error); + } + }); + + // New endpoint: Complete checkout after successful payment + router.post('/complete-checkout', async (req, res, next) => { + try { + const { userId, orderId, sessionId } = req.body; + + if (req.user.id !== userId) { + return res.status(403).json({ + error: true, + message: 'You can only complete your own checkout' + }); + } + + // Verify the order exists and belongs to the user + const orderResult = await query( + 'SELECT * FROM orders WHERE id = $1 AND user_id = $2', + [orderId, userId] + ); + + if (orderResult.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Order not found' + }); + } + + // Begin transaction + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + // Update order status and payment info + await client.query( + 'UPDATE orders SET status = $1, payment_completed = true, payment_id = $2 WHERE id = $3', + ['processing', sessionId, orderId] + ); + + // Get cart + const cartResult = await client.query( + 'SELECT * FROM carts WHERE user_id = $1', + [userId] + ); + + if (cartResult.rows.length > 0) { + const cartId = cartResult.rows[0].id; + + // Get cart items to update product stock + const cartItemsResult = await client.query( + 'SELECT * FROM cart_items WHERE cart_id = $1', + [cartId] + ); + + // Update product stock + for (const item of cartItemsResult.rows) { + await client.query( + 'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2', + [item.quantity, item.product_id] + ); + } + + // Clear cart + await client.query( + 'DELETE FROM cart_items WHERE cart_id = $1', + [cartId] + ); + } + + await client.query('COMMIT'); + + res.status(200).json({ + success: true, + message: 'Order completed successfully', orderId }); } catch (error) { diff --git a/backend/src/routes/stripePayment.js b/backend/src/routes/stripePayment.js new file mode 100644 index 0000000..ecc9f60 --- /dev/null +++ b/backend/src/routes/stripePayment.js @@ -0,0 +1,198 @@ +const express = require('express'); +const router = express.Router(); +const stripe = require('stripe'); +const config = require('../config'); + +module.exports = (pool, query, authMiddleware) => { + // Apply authentication middleware to all routes + router.use(authMiddleware); + + // Initialize Stripe with the secret key from config + const stripeClient = stripe(config.payment?.stripeSecretKey); + + // Create checkout session + router.post('/create-checkout-session', async (req, res, next) => { + try { + const { cartItems, shippingAddress, userId, orderId } = req.body; + + if (!cartItems || cartItems.length === 0) { + return res.status(400).json({ + error: true, + message: 'Cart items are required' + }); + } + + // Verify user has access to this cart + if (req.user.id !== userId) { + return res.status(403).json({ + error: true, + message: 'You can only checkout your own cart' + }); + } + + // Format line items for Stripe + const lineItems = cartItems.map(item => { + return { + price_data: { + currency: 'usd', + product_data: { + name: item.name, + description: item.description ? item.description.substring(0, 500) : undefined, + images: item.primary_image ? [ + `${config.site.protocol}://${config.site.apiDomain}${item.primary_image.path}` + ] : undefined, + }, + unit_amount: Math.round(parseFloat(item.price) * 100), // Convert to cents + }, + quantity: item.quantity, + }; + }); + + // Create metadata with shipping info and order reference + const metadata = { + order_id: orderId, + user_id: userId, + shipping_address: JSON.stringify(shippingAddress), + }; + + // Create Stripe checkout session + const session = await stripeClient.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: lineItems, + mode: 'payment', + success_url: `${config.site.protocol}://${config.site.domain}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${config.site.protocol}://${config.site.domain}/checkout/cancel`, + metadata: metadata, + shipping_address_collection: { + allowed_countries: ['US', 'CA'], + }, + shipping_options: [ + { + shipping_rate_data: { + type: 'fixed_amount', + fixed_amount: { + amount: 0, + currency: 'usd', + }, + display_name: 'Free shipping', + delivery_estimate: { + minimum: { + unit: 'business_day', + value: 5, + }, + maximum: { + unit: 'business_day', + value: 7, + }, + }, + }, + }, + ], + }); + + // Return the session ID to the client + res.json({ + success: true, + sessionId: session.id, + url: session.url + }); + } catch (error) { + console.error('Stripe checkout error:', error); + next(error); + } + }); + + // Verify payment status + router.get('/session-status/:sessionId', async (req, res, next) => { + try { + const { sessionId } = req.params; + + // Retrieve the session from Stripe + const session = await stripeClient.checkout.sessions.retrieve(sessionId); + + res.json({ + success: true, + status: session.payment_status, + metadata: session.metadata + }); + } catch (error) { + console.error('Error checking session status:', error); + next(error); + } + }); + + // Webhook to handle events from Stripe + router.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => { + // This needs to be called with raw body data + const payload = req.body; + const sig = req.headers['stripe-signature']; + + let event; + + try { + // Verify the webhook signature + const webhookSecret = config.payment?.stripeWebhookSecret; + if (!webhookSecret) { + throw new Error('Stripe webhook secret is not configured'); + } + + event = stripeClient.webhooks.constructEvent(payload, sig, webhookSecret); + + // Handle the event + switch (event.type) { + case 'checkout.session.completed': + const session = event.data.object; + + // Check if payment was successful + if (session.payment_status === 'paid') { + // Get metadata + const { order_id, user_id } = session.metadata; + + if (order_id) { + // Update order status in database + await query( + 'UPDATE orders SET status = $1, payment_completed = true, payment_id = $2 WHERE id = $3', + ['processing', session.id, order_id] + ); + + console.log(`Payment completed for order ${order_id}`); + } + } + break; + + case 'payment_intent.payment_failed': + const paymentIntent = event.data.object; + console.log(`Payment failed: ${paymentIntent.last_payment_error?.message}`); + + // Handle failed payment + if (paymentIntent.metadata?.order_id) { + await query( + 'UPDATE orders SET status = $1, payment_notes = $2 WHERE id = $3', + ['payment_failed', 'Payment attempt failed', paymentIntent.metadata.order_id] + ); + } + break; + + default: + console.log(`Unhandled event type ${event.type}`); + } + + // Return a 200 success response + res.status(200).send(); + } catch (err) { + console.error(`Webhook Error: ${err.message}`); + return res.status(400).send(`Webhook Error: ${err.message}`); + } + }); + router.get('/config', async (req, res, next) => { + try { + res.json({ + stripePublicKey: config.payment?.stripePublicKey || '', + stripeEnabled: config.payment?.stripeEnabled || false + }); + } catch (error) { + next(error); + } + }); + return router; +}; \ No newline at end of file diff --git a/db/init/09-system-settings.sql b/db/init/09-system-settings.sql index 9730e73..fe6e2cc 100644 --- a/db/init/09-system-settings.sql +++ b/db/init/09-system-settings.sql @@ -34,8 +34,6 @@ VALUES ('site_environment', NULL, 'site'), -- Payment Settings - ('stripe_public_key', NULL, 'payment'), - ('stripe_secret_key', NULL, 'payment'), ('currency', 'CAD', 'payment'), ('tax_rate', '0', 'payment'), diff --git a/db/init/10-payment.sql b/db/init/10-payment.sql new file mode 100644 index 0000000..747c02c --- /dev/null +++ b/db/init/10-payment.sql @@ -0,0 +1,22 @@ +-- Add payment related columns to the orders table +ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_completed BOOLEAN DEFAULT FALSE; +ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_id VARCHAR(255); +ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50); +ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_notes TEXT; + +-- Add Stripe settings if they don't exist +INSERT INTO system_settings (key, value, category) +VALUES ('stripe_public_key', '', 'payment') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO system_settings (key, value, category) +VALUES ('stripe_secret_key', '', 'payment') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO system_settings (key, value, category) +VALUES ('stripe_webhook_secret', '', 'payment') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO system_settings (key, value, category) +VALUES ('stripe_enabled', 'false', 'payment') +ON CONFLICT (key) DO NOTHING; \ No newline at end of file diff --git a/fileStructure.txt b/fileStructure.txt index b9272c4..d058992 100644 --- a/fileStructure.txt +++ b/fileStructure.txt @@ -1,96 +1,117 @@ -backend/ -├── public/ -├── node_modules/ -├── src/ -│ ├── routes/ -│ │ ├── auth.js -│ │ ├── products.js -│ │ ├── productAdminImages.js -│ │ ├── images.js -│ │ ├── productAdmin.js -│ │ └── cart.js -│ ├── middleware/ -│ │ ├── upload.js -│ │ ├── auth.js -│ │ └── adminAuth.js -│ ├── db/ -│ │ └── index.js -│ ├── index.js -│ └── config.js -│ └── Dockerfile -├── package.json -├── .env -├── README.md -└── .gitignore -db/ -├── init/ -│ ├── 05-admin-role.sql -│ ├── 02-seed.sql -│ ├── 04-product-images.sql -│ ├── 03-api-key.sql -│ ├── 01-schema.sql -frontend/ -├── node_modules/ -├── public/ -├── src/ -│ ├── pages/ -│ │ ├── Admin/ -│ │ │ ├── ProductEditPage.jsx -│ │ │ ├── DashboardPage.jsx -│ │ │ └── ProductsPage.jsx -│ │ ├── ProductsPage.jsx -│ │ ├── HomePage.jsx -│ │ ├── CartPage.jsx -│ │ ├── ProductDetailPage.jsx -│ │ ├── VerifyPage.jsx -│ │ ├── CheckoutPage.jsx -│ │ ├── RegisterPage.jsx -│ │ ├── NotFoundPage.jsx -│ │ └── LoginPage.jsx -│ ├── utils/ -│ │ └── imageUtils.js -│ ├── components/ -│ │ ├── ImageUploader.jsx -│ │ ├── ProductImage.jsx -│ │ ├── Footer.jsx -│ │ ├── ProtectedRoute.jsx -│ │ └── Notifications.jsx -│ ├── services/ -│ │ ├── imageService.js -│ │ ├── productService.js -│ │ ├── cartService.js -│ │ ├── authService.js -│ │ └── api.js -│ ├── hooks/ -│ │ ├── apiHooks.js -│ │ └── reduxHooks.js -│ ├── layouts/ -│ │ ├── MainLayout.jsx -│ │ ├── AuthLayout.jsx -│ │ └── AdminLayout.jsx -│ ├── theme/ -│ │ ├── index.js -│ │ └── ThemeProvider.jsx -│ ├── features/ -│ │ ├── ui/ -│ │ │ └── uiSlice.js -│ │ ├── cart/ -│ │ │ └── cartSlice.js -│ │ ├── auth/ -│ │ │ └── authSlice.js -│ │ └── store/ -│ │ └── index.js -│ ├── assets/ -│ ├── App.jsx -│ ├── main.jsx -│ └── config.js -├── package.json -├── package-lock.json -├── vite.config.js -├── Dockerfile -├── nginx.conf -├── index.html -├── README.md -├── .env -├── setup-frontend.sh -└── docker-compose.yml \ No newline at end of file +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 +├── 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 +│ │ ├── models/ +│ │ │ └── SystemSettings.js # System settings model +│ │ ├── 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3d5e0ad..f716c70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,8 @@ "@mui/icons-material": "^5.14.19", "@mui/material": "^5.14.19", "@reduxjs/toolkit": "^2.0.1", + "@stripe/react-stripe-js": "^2.4.0", + "@stripe/stripe-js": "^2.2.0", "@tanstack/react-query": "^5.12.2", "@tanstack/react-query-devtools": "^5.12.2", "axios": "^1.6.2", @@ -1841,6 +1843,24 @@ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" }, + "node_modules/@stripe/react-stripe-js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.4.0.tgz", + "integrity": "sha512-1jVQEL3OuhuzNlf4OdfqovHt+MkWh8Uh8xpLxx/xUFUDdF+7/kDOrGKy+xJO3WLCfZUL7NAy+/ypwXbbYZi0tg==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": "^1.44.1 || ^2.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.3.0.tgz", + "integrity": "sha512-iTwzjw1ORUR+1pH21+C/M05w+Jh5hAuE4QUei7Gnku65N7QpEaHtyVszYMYDBs6iNyLrD1tfQTSrjD6NkOA/ww==" + }, "node_modules/@tanstack/query-core": { "version": "5.74.4", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 031594b..fc40a51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,8 @@ "@mui/icons-material": "^5.14.19", "@mui/material": "^5.14.19", "@reduxjs/toolkit": "^2.0.1", + "@stripe/react-stripe-js": "^2.4.0", + "@stripe/stripe-js": "^2.2.0", "@tanstack/react-query": "^5.12.2", "@tanstack/react-query-devtools": "^5.12.2", "axios": "^1.6.2", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c82ca31..d8ffacd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { Suspense, lazy } from 'react'; import { CircularProgress, Box } from '@mui/material'; import Notifications from './components/Notifications'; import ProtectedRoute from './components/ProtectedRoute'; +import { StripeProvider } from './context/StripeContext'; // Layouts import MainLayout from './layouts/MainLayout'; @@ -15,6 +16,8 @@ const ProductsPage = lazy(() => import('./pages/ProductsPage')); const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage')); const CartPage = lazy(() => import('./pages/CartPage')); const CheckoutPage = lazy(() => import('./pages/CheckoutPage')); +const PaymentSuccessPage = lazy(() => import('./pages/PaymentSuccessPage')); +const PaymentCancelPage = lazy(() => import('./pages/PaymentCancelPage')); const LoginPage = lazy(() => import('./pages/LoginPage')); const RegisterPage = lazy(() => import('./pages/RegisterPage')); const VerifyPage = lazy(() => import('./pages/VerifyPage')); @@ -26,6 +29,7 @@ const AdminCustomersPage = lazy(() => import('./pages/Admin/CustomersPage')); const AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage')); const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage')); const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); + // Loading component for suspense fallback const LoadingComponent = () => ( @@ -35,55 +39,68 @@ const LoadingComponent = () => ( function App() { return ( - }> - - - {/* Main routes with MainLayout */} - }> - } /> - } /> - } /> - - + + }> + + + {/* Main routes with MainLayout */} + }> + } /> + } /> + } /> + + + + } /> + + + + } /> + {/* Payment success and cancel routes */} + + + + } /> + + + + } /> + + + {/* Auth routes with AuthLayout */} + }> + } /> + } /> + + + {/* Verification route - standalone page */} + } /> + + {/* Admin routes with AdminLayout - protected for admins only */} + + - } /> - - - - } /> - + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* Auth routes with AuthLayout */} - }> - } /> - } /> - - - {/* Verification route - standalone page */} - } /> - - {/* Admin routes with AdminLayout - protected for admins only */} - - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Catch-all route for 404s */} - } /> - - + {/* Catch-all route for 404s */} + } /> + + + ); } diff --git a/frontend/src/components/StripePaymentForm.jsx b/frontend/src/components/StripePaymentForm.jsx new file mode 100644 index 0000000..4f3e284 --- /dev/null +++ b/frontend/src/components/StripePaymentForm.jsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { + PaymentElement, + useStripe as useStripeJs, + useElements +} from '@stripe/react-stripe-js'; +import { Box, Button, CircularProgress, Alert, Typography } from '@mui/material'; +import { useStripe } from '../context/StripeContext'; + +const StripePaymentForm = ({ orderId, onSuccess, onError }) => { + const stripe = useStripeJs(); + const elements = useElements(); + const { completeOrder } = useStripe(); + const [isLoading, setIsLoading] = useState(false); + const [message, setMessage] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!stripe || !elements) { + // Stripe.js hasn't loaded yet + return; + } + + setIsLoading(true); + setMessage(null); + + try { + // Submit the form + const { error, paymentIntent } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}/checkout/confirmation?order_id=${orderId}`, + }, + redirect: 'if_required', + }); + + if (error) { + setMessage(error.message || 'An unexpected error occurred'); + onError(error.message); + } else if (paymentIntent && paymentIntent.status === 'succeeded') { + // Call our backend to update the order status + await completeOrder(orderId, paymentIntent.id); + setMessage('Payment successful!'); + onSuccess(paymentIntent); + } else { + setMessage('Payment processing. Please wait for confirmation.'); + } + } catch (err) { + console.error('Payment error:', err); + setMessage(err.message || 'An error occurred during payment processing'); + onError(err.message); + } finally { + setIsLoading(false); + } + }; + + return ( + + {message && ( + + {message} + + )} + + + Enter your payment details + + + + + + + + + ); +}; + +export default StripePaymentForm; \ No newline at end of file diff --git a/frontend/src/context/StripeContext.jsx b/frontend/src/context/StripeContext.jsx new file mode 100644 index 0000000..3318aef --- /dev/null +++ b/frontend/src/context/StripeContext.jsx @@ -0,0 +1,151 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { loadStripe } from '@stripe/stripe-js'; +import { Elements } from '@stripe/react-stripe-js'; +import axios from 'axios'; +import config from '../config'; + +// Create the context +const StripeContext = createContext(); + +export const StripeProvider = ({ children }) => { + const [stripePromise, setStripePromise] = useState(null); + const [clientSecret, setClientSecret] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + // Try to load Stripe public key from environment + let publicKey = import.meta.env.VITE_STRIPE_PUBLIC_KEY; + + // If not found, fetch from API + if (!publicKey) { + // Fetch Stripe public key from backend + axios.get('/api/payment/config') + .then(response => { + if (response.data.stripePublicKey) { + loadStripeInstance(response.data.stripePublicKey); + } else { + setError('Stripe public key not found'); + setIsLoading(false); + } + }) + .catch(err => { + console.error('Error fetching Stripe config:', err); + setError('Failed to load payment configuration'); + setIsLoading(false); + }); + } else { + loadStripeInstance(publicKey); + } + }, []); + + const loadStripeInstance = (publicKey) => { + try { + const stripe = loadStripe(publicKey); + setStripePromise(stripe); + setIsLoading(false); + } catch (err) { + console.error('Error loading Stripe:', err); + setError('Failed to initialize payment system'); + setIsLoading(false); + } + }; + + // Create a checkout session + const createCheckoutSession = async (cartItems, orderId, shippingAddress, userId) => { + try { + const response = await axios.post('/api/payment/create-checkout-session', { + cartItems, + orderId, + shippingAddress, + userId + }); + + return response.data; + } catch (err) { + console.error('Error creating checkout session:', err); + throw new Error(err.response?.data?.message || 'Failed to create checkout session'); + } + }; + + // Check session status + const checkSessionStatus = async (sessionId) => { + try { + const response = await axios.get(`/api/payment/session-status/${sessionId}`); + return response.data; + } catch (err) { + console.error('Error checking session status:', err); + throw new Error(err.response?.data?.message || 'Failed to check payment status'); + } + }; + + // Complete the order after successful payment + const completeOrder = async (orderId, sessionId, userId) => { + try { + const response = await axios.post('/api/cart/complete-checkout', { + orderId, + sessionId, + userId + }); + return response.data; + } catch (err) { + console.error('Error completing order:', err); + throw new Error(err.response?.data?.message || 'Failed to complete order'); + } + }; + + const value = { + stripePromise, + clientSecret, + isLoading, + error, + createCheckoutSession, + checkSessionStatus, + completeOrder + }; + + return ( + + {children} + + ); +}; + +export const useStripe = () => { + const context = useContext(StripeContext); + if (!context) { + throw new Error('useStripe must be used within a StripeProvider'); + } + return context; +}; + +// Wrapper component for Elements +export const StripeElementsProvider = ({ children, clientSecret }) => { + const { stripePromise } = useStripe(); + + const options = { + clientSecret, + appearance: { + theme: 'stripe', + variables: { + colorPrimary: '#6200ea', + colorBackground: '#ffffff', + colorText: '#30313d', + colorDanger: '#df1b41', + fontFamily: 'Roboto, sans-serif', + spacingUnit: '4px', + borderRadius: '4px' + } + } + }; + + if (!clientSecret) { + return children; + } + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/Admin/SettingsPage.jsx b/frontend/src/pages/Admin/SettingsPage.jsx index 5587463..db37bb6 100644 --- a/frontend/src/pages/Admin/SettingsPage.jsx +++ b/frontend/src/pages/Admin/SettingsPage.jsx @@ -258,78 +258,175 @@ const AdminSettingsPage = () => { + {category === 'payment' && + + + } + label={formData['stripe_enabled'] === 'true' ? 'Stripe is Enabled' : 'Stripe is Disabled'} + /> + + + + + + + + + togglePasswordVisibility('stripe_secret_key')} + edge="end" + > + {showPasswords['stripe_secret_key'] ? : } + + + ) + }} + /> + + + + + togglePasswordVisibility('stripe_webhook_secret')} + edge="end" + > + {showPasswords['stripe_webhook_secret'] ? : } + + + ) + }} + /> + + + + + + To set up Stripe integration: + +
    +
  1. Create a Stripe account at stripe.com
  2. +
  3. Obtain your API keys from the Stripe Dashboard
  4. +
  5. Set up a webhook endpoint at: {`${window.location.protocol}//${window.location.host}/api/payment/webhook`}
  6. +
  7. Copy the webhook signing secret here
  8. +
+
+
+
} - {settingsData[category].map((setting) => ( - - - - - - {setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + {settingsData[category].map((setting) => { + if(setting.key.toUpperCase().includes("STRIPE")) + return null + + return ( + + + + + + {setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} + + + handleOpenDeleteDialog(setting)} + > + + + + + + {setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? ( + + togglePasswordVisibility(setting.key)} + edge="end" + > + {showPasswords[setting.key] ? : } + + + ) + }} + /> + ) : setting.key.includes('enabled') || setting.key.includes('active') ? ( + + } + label={formData[setting.key] === 'true' ? 'Enabled' : 'Disabled'} + /> + ) : ( + + )} + + + Last updated: {new Date(setting.updated_at).toLocaleString()} - - handleOpenDeleteDialog(setting)} - > - - - - - - {setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? ( - - togglePasswordVisibility(setting.key)} - edge="end" - > - {showPasswords[setting.key] ? : } - - - ) - }} - /> - ) : setting.key.includes('enabled') || setting.key.includes('active') ? ( - - } - label={formData[setting.key] === 'true' ? 'Enabled' : 'Disabled'} - /> - ) : ( - - )} - - - Last updated: {new Date(setting.updated_at).toLocaleString()} - - - - - ))} + + + + ) + } + )} ))} diff --git a/frontend/src/pages/CheckoutPage.jsx b/frontend/src/pages/CheckoutPage.jsx index a3ddbf3..999b80e 100644 --- a/frontend/src/pages/CheckoutPage.jsx +++ b/frontend/src/pages/CheckoutPage.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Box, Typography, @@ -21,6 +21,8 @@ import { 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'; // Checkout steps const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation']; @@ -30,9 +32,14 @@ const CheckoutPage = () => { const { user } = useAuth(); const { items, total, itemCount } = useCart(); const checkout = useCheckout(); + const { createCheckoutSession, isLoading: isStripeLoading } = useStripe(); // State for checkout steps const [activeStep, setActiveStep] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [orderId, setOrderId] = useState(null); + const [checkoutUrl, setCheckoutUrl] = useState(null); // State for form data const [formData, setFormData] = useState({ @@ -107,29 +114,61 @@ const CheckoutPage = () => { }; // Handle place order - const handlePlaceOrder = () => { + const handlePlaceOrder = async () => { if (!user || !items || items.length === 0) { return; } - // Format shipping address for API - const shippingAddress = `${formData.firstName} ${formData.lastName} + setIsProcessing(true); + setError(null); + + try { + // Format shipping address + const shippingAddress = `${formData.firstName} ${formData.lastName} ${formData.address} ${formData.city}, ${formData.state} ${formData.zipCode} ${formData.country} ${formData.email}`; - - checkout.mutate({ - userId: user, - shippingAddress - }, { - onSuccess: () => { - // Move to confirmation step - setActiveStep(3); + + // Call the checkout API to create the order + const orderResponse = await checkout.mutateAsync({ + userId: user, + shippingAddress + }); + + // Store the order ID for later use + setOrderId(orderResponse.orderId); + + // Proceed to payment step + setActiveStep(2); + + // Create a Stripe checkout session + const session = await createCheckoutSession( + orderResponse.cartItems, + orderResponse.orderId, + shippingAddress, + user + ); + + // Redirect to Stripe Checkout + if (session.url) { + setCheckoutUrl(session.url); } - }); + } catch (error) { + console.error('Checkout error:', error); + setError(error.message || 'An error occurred during checkout'); + } finally { + setIsProcessing(false); + } }; + // Redirect to Stripe checkout when the URL is available + useEffect(() => { + if (checkoutUrl) { + window.location.href = checkoutUrl; + } + }, [checkoutUrl]); + // If no items in cart, redirect to cart page if (!items || items.length === 0) { return ( @@ -330,24 +369,26 @@ ${formData.email}`; ); case 2: - // Placeholder for payment (in a real app, this would have a payment form) return ( - - This is a demo application. No actual payment will be processed. - + {isStripeLoading || isProcessing ? ( + + + + Preparing secure payment... + + + ) : ( + + You will be redirected to our secure payment processor. + + )} - - Payment Method - - - - For this demo, we'll simulate a successful payment. - - - - Total to pay: ${total.toFixed(2)} - + {error && ( + + {error} + + )} ); case 3: @@ -362,7 +403,7 @@ ${formData.email}`; - Your order number is: #{Math.floor(100000 + Math.random() * 900000)} + Your order number is: #{orderId?.substring(0, 8) || Math.floor(100000 + Math.random() * 900000)} @@ -402,28 +443,28 @@ ${formData.email}`; {getStepContent(activeStep)} - {activeStep !== 0 && activeStep !== 3 && ( + {activeStep !== 0 && activeStep !== 3 && !isProcessing && ( )} - {activeStep !== 3 ? ( + {activeStep !== 2 && activeStep !== 3 ? ( - ) : ( + ) : activeStep === 3 ? ( - )} + ) : null} diff --git a/frontend/src/pages/PaymentCancelPage.jsx b/frontend/src/pages/PaymentCancelPage.jsx new file mode 100644 index 0000000..5c41770 --- /dev/null +++ b/frontend/src/pages/PaymentCancelPage.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { + Box, + Typography, + Paper, + Button, + Alert +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import CancelIcon from '@mui/icons-material/Cancel'; + +const PaymentCancelPage = () => { + const navigate = useNavigate(); + + return ( + + + + + + Payment Cancelled + + + + Your payment was cancelled and you have not been charged. + + + + You can try again or choose a different payment method. + + + + + + + + + + ); +}; + +export default PaymentCancelPage; \ No newline at end of file diff --git a/frontend/src/pages/PaymentSuccessPage.jsx b/frontend/src/pages/PaymentSuccessPage.jsx new file mode 100644 index 0000000..c424712 --- /dev/null +++ b/frontend/src/pages/PaymentSuccessPage.jsx @@ -0,0 +1,147 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { + Box, + Typography, + Paper, + Button, + CircularProgress, + Alert, + Divider, + CheckCircleOutline as CheckCircleIcon +} from '@mui/material'; +import { useStripe } from '../context/StripeContext'; +import { useAuth } from '../hooks/reduxHooks'; + +const PaymentSuccessPage = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { checkSessionStatus, completeOrder } = useStripe(); + const { user } = useAuth(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [orderDetails, setOrderDetails] = useState(null); + + useEffect(() => { + const processPayment = async () => { + try { + // Get the session ID from the URL + const params = new URLSearchParams(location.search); + const sessionId = params.get('session_id'); + + if (!sessionId) { + setError('No session ID found. Please contact customer support.'); + setIsLoading(false); + return; + } + + // Check the session status + const sessionData = await checkSessionStatus(sessionId); + + if (sessionData.status !== 'paid') { + setError('Payment not completed. Please try again or contact customer support.'); + setIsLoading(false); + return; + } + + // Get metadata from session + const { metadata } = sessionData; + + if (!metadata || !metadata.order_id) { + setError('Missing order information. Please contact customer support.'); + setIsLoading(false); + return; + } + + // Complete the order in our system + const orderResult = await completeOrder( + metadata.order_id, + sessionId, + user + ); + + setOrderDetails({ + orderId: metadata.order_id, + sessionId + }); + + setIsLoading(false); + } catch (err) { + console.error('Error processing payment success:', err); + setError(err.message || 'An error occurred while processing your payment.'); + setIsLoading(false); + } + }; + + processPayment(); + }, [location.search, checkSessionStatus, completeOrder, user]); + + if (isLoading) { + return ( + + + + Processing your payment... + + + ); + } + + if (error) { + return ( + + + {error} + + + + ); + } + + return ( + + + + + + Payment Successful! + + + + Thank you for your purchase. Your order has been completed and a confirmation email will be sent shortly. + + + + + + + Order Reference: {orderDetails?.orderId.substring(0, 8)}... + + + + + + + + + + + ); +}; + +export default PaymentSuccessPage; \ No newline at end of file