Stripe integration

This commit is contained in:
2ManyProjects 2025-04-26 19:25:36 -05:00
parent c40852703d
commit c0b9c6e4f1
17 changed files with 1299 additions and 269 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),

22
db/init/10-payment.sql Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -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 = () => (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
@ -35,55 +39,68 @@ const LoadingComponent = () => (
function App() {
return (
<Suspense fallback={<LoadingComponent />}>
<Notifications />
<Routes>
{/* Main routes with MainLayout */}
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="cart" element={
<ProtectedRoute>
<CartPage />
<StripeProvider>
<Suspense fallback={<LoadingComponent />}>
<Notifications />
<Routes>
{/* Main routes with MainLayout */}
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="cart" element={
<ProtectedRoute>
<CartPage />
</ProtectedRoute>
} />
<Route path="checkout" element={
<ProtectedRoute>
<CheckoutPage />
</ProtectedRoute>
} />
{/* Payment success and cancel routes */}
<Route path="checkout/success" element={
<ProtectedRoute>
<PaymentSuccessPage />
</ProtectedRoute>
} />
<Route path="checkout/cancel" element={
<ProtectedRoute>
<PaymentCancelPage />
</ProtectedRoute>
} />
</Route>
{/* Auth routes with AuthLayout */}
<Route path="/auth" element={<AuthLayout />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
</Route>
{/* Verification route - standalone page */}
<Route path="/verify" element={<VerifyPage />} />
{/* Admin routes with AdminLayout - protected for admins only */}
<Route path="/admin" element={
<ProtectedRoute requireAdmin={true} redirectTo="/">
<AdminLayout />
</ProtectedRoute>
} />
<Route path="checkout" element={
<ProtectedRoute>
<CheckoutPage />
</ProtectedRoute>
} />
</Route>
}>
<Route index element={<AdminDashboardPage />} />
<Route path="products" element={<AdminProductsPage />} />
<Route path="products/:id" element={<AdminProductEditPage />} />
<Route path="products/new" element={<AdminProductEditPage />} />
<Route path="categories" element={<AdminCategoriesPage />} />
<Route path="customers" element={<AdminCustomersPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
</Route>
{/* Auth routes with AuthLayout */}
<Route path="/auth" element={<AuthLayout />}>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
</Route>
{/* Verification route - standalone page */}
<Route path="/verify" element={<VerifyPage />} />
{/* Admin routes with AdminLayout - protected for admins only */}
<Route path="/admin" element={
<ProtectedRoute requireAdmin={true} redirectTo="/">
<AdminLayout />
</ProtectedRoute>
}>
<Route index element={<AdminDashboardPage />} />
<Route path="products" element={<AdminProductsPage />} />
<Route path="products/:id" element={<AdminProductEditPage />} />
<Route path="products/new" element={<AdminProductEditPage />} />
<Route path="categories" element={<AdminCategoriesPage />} />
<Route path="customers" element={<AdminCustomersPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
</Route>
{/* Catch-all route for 404s */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
{/* Catch-all route for 404s */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</StripeProvider>
);
}

View file

@ -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 (
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
{message && (
<Alert
severity={message.includes('successful') ? 'success' : 'error'}
sx={{ mb: 3 }}
>
{message}
</Alert>
)}
<Typography variant="h6" gutterBottom>
Enter your payment details
</Typography>
<Box sx={{ mb: 3 }}>
<PaymentElement />
</Box>
<Button
type="submit"
variant="contained"
color="primary"
fullWidth
size="large"
disabled={isLoading || !stripe || !elements}
>
{isLoading ? (
<>
<CircularProgress size={24} sx={{ mr: 1 }} />
Processing...
</>
) : (
'Pay Now'
)}
</Button>
</Box>
);
};
export default StripePaymentForm;

View file

@ -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 (
<StripeContext.Provider value={value}>
{children}
</StripeContext.Provider>
);
};
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 (
<Elements stripe={stripePromise} options={options}>
{children}
</Elements>
);
};

View file

@ -258,78 +258,175 @@ const AdminSettingsPage = () => {
</Button>
</Box>
{category === 'payment' && <Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={formData['stripe_enabled'] === 'true'}
onChange={handleChange}
name="stripe_enabled"
color="primary"
/>
}
label={formData['stripe_enabled'] === 'true' ? 'Stripe is Enabled' : 'Stripe is Disabled'}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
name="stripe_public_key"
label="Stripe Public Key"
value={formData['stripe_public_key'] || ''}
onChange={handleChange}
variant="outlined"
helperText="Publishable key that starts with 'pk_test_' or 'pk_live_'"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
name="stripe_secret_key"
label="Stripe Secret Key"
type={showPasswords['stripe_secret_key'] ? 'text' : 'password'}
value={formData['stripe_secret_key'] || ''}
onChange={handleChange}
variant="outlined"
helperText="Secret key that starts with 'sk_test_' or 'sk_live_'"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility('stripe_secret_key')}
edge="end"
>
{showPasswords['stripe_secret_key'] ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
name="stripe_webhook_secret"
label="Stripe Webhook Secret"
type={showPasswords['stripe_webhook_secret'] ? 'text' : 'password'}
value={formData['stripe_webhook_secret'] || ''}
onChange={handleChange}
variant="outlined"
helperText="Webhook signing secret that starts with 'whsec_'"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility('stripe_webhook_secret')}
edge="end"
>
{showPasswords['stripe_webhook_secret'] ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
</Grid>
<Grid item xs={12}>
<Alert severity="info">
<Typography variant="body2">
To set up Stripe integration:
</Typography>
<ol>
<li>Create a Stripe account at <a href="https://stripe.com" target="_blank" rel="noopener">stripe.com</a></li>
<li>Obtain your API keys from the Stripe Dashboard</li>
<li>Set up a webhook endpoint at: <code>{`${window.location.protocol}//${window.location.host}/api/payment/webhook`}</code></li>
<li>Copy the webhook signing secret here</li>
</ol>
</Alert>
</Grid>
</Grid>}
<Grid container spacing={3}>
{settingsData[category].map((setting) => (
<Grid item xs={12} md={6} key={setting.key}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
{setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
{settingsData[category].map((setting) => {
if(setting.key.toUpperCase().includes("STRIPE"))
return null
return (
<Grid item xs={12} md={6} key={setting.key}>
<Card variant="outlined">
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1" fontWeight="bold">
{setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</Typography>
<Tooltip title="Delete Setting">
<IconButton
color="error"
size="small"
onClick={() => handleOpenDeleteDialog(setting)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
{setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? (
<TextField
fullWidth
name={setting.key}
label="Value"
type={showPasswords[setting.key] ? 'text' : 'password'}
value={formData[setting.key] || ''}
onChange={handleChange}
variant="outlined"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility(setting.key)}
edge="end"
>
{showPasswords[setting.key] ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
) : setting.key.includes('enabled') || setting.key.includes('active') ? (
<FormControlLabel
control={
<Switch
checked={formData[setting.key] === 'true'}
onChange={handleChange}
name={setting.key}
color="primary"
/>
}
label={formData[setting.key] === 'true' ? 'Enabled' : 'Disabled'}
/>
) : (
<TextField
fullWidth
name={setting.key}
label="Value"
value={formData[setting.key] || ''}
onChange={handleChange}
variant="outlined"
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Last updated: {new Date(setting.updated_at).toLocaleString()}
</Typography>
<Tooltip title="Delete Setting">
<IconButton
color="error"
size="small"
onClick={() => handleOpenDeleteDialog(setting)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
{setting.key.includes('password') || setting.key.includes('secret') || setting.key.includes('key') ? (
<TextField
fullWidth
name={setting.key}
label="Value"
type={showPasswords[setting.key] ? 'text' : 'password'}
value={formData[setting.key] || ''}
onChange={handleChange}
variant="outlined"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => togglePasswordVisibility(setting.key)}
edge="end"
>
{showPasswords[setting.key] ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
) : setting.key.includes('enabled') || setting.key.includes('active') ? (
<FormControlLabel
control={
<Switch
checked={formData[setting.key] === 'true'}
onChange={handleChange}
name={setting.key}
color="primary"
/>
}
label={formData[setting.key] === 'true' ? 'Enabled' : 'Disabled'}
/>
) : (
<TextField
fullWidth
name={setting.key}
label="Value"
value={formData[setting.key] || ''}
onChange={handleChange}
variant="outlined"
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
Last updated: {new Date(setting.updated_at).toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</CardContent>
</Card>
</Grid>
)
}
)}
</Grid>
</TabPanel>
))}

View file

@ -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}`;
</Box>
);
case 2:
// Placeholder for payment (in a real app, this would have a payment form)
return (
<Box sx={{ mt: 3 }}>
<Alert severity="info" sx={{ mb: 3 }}>
This is a demo application. No actual payment will be processed.
</Alert>
{isStripeLoading || isProcessing ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={40} />
<Typography variant="h6" sx={{ ml: 2 }}>
Preparing secure payment...
</Typography>
</Box>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
You will be redirected to our secure payment processor.
</Alert>
)}
<Typography variant="h6" gutterBottom>
Payment Method
</Typography>
<Typography paragraph>
For this demo, we'll simulate a successful payment.
</Typography>
<Typography paragraph>
Total to pay: ${total.toFixed(2)}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
</Box>
);
case 3:
@ -362,7 +403,7 @@ ${formData.email}`;
</Typography>
<Typography paragraph>
Your order number is: #{Math.floor(100000 + Math.random() * 900000)}
Your order number is: #{orderId?.substring(0, 8) || Math.floor(100000 + Math.random() * 900000)}
</Typography>
<Typography paragraph>
@ -402,28 +443,28 @@ ${formData.email}`;
{getStepContent(activeStep)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
{activeStep !== 0 && activeStep !== 3 && (
{activeStep !== 0 && activeStep !== 3 && !isProcessing && (
<Button
onClick={handleBack}
sx={{ mr: 1 }}
disabled={checkout.isLoading}
disabled={checkout.isLoading || isProcessing}
>
Back
</Button>
)}
{activeStep !== 3 ? (
{activeStep !== 2 && activeStep !== 3 ? (
<Button
variant="contained"
onClick={handleNext}
disabled={checkout.isLoading}
disabled={checkout.isLoading || isProcessing}
>
{activeStep === steps.length - 2 ? 'Place Order' : 'Next'}
{checkout.isLoading && (
{(checkout.isLoading || isProcessing) && (
<CircularProgress size={24} sx={{ ml: 1 }} />
)}
</Button>
) : (
) : activeStep === 3 ? (
<Button
variant="contained"
component={RouterLink}
@ -431,7 +472,7 @@ ${formData.email}`;
>
Return to Home
</Button>
)}
) : null}
</Box>
</Paper>
</Box>

View file

@ -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 (
<Box sx={{ py: 4 }}>
<Paper sx={{ p: 4, maxWidth: 600, mx: 'auto', textAlign: 'center' }}>
<CancelIcon color="error" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
Payment Cancelled
</Typography>
<Alert severity="info" sx={{ mb: 3, mt: 2 }}>
Your payment was cancelled and you have not been charged.
</Alert>
<Typography variant="body1" paragraph>
You can try again or choose a different payment method.
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button
variant="outlined"
onClick={() => navigate('/products')}
>
Continue Shopping
</Button>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/cart')}
>
Return to Cart
</Button>
</Box>
</Paper>
</Box>
);
};
export default PaymentCancelPage;

View file

@ -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 (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6 }}>
<CircularProgress size={60} sx={{ mb: 3 }} />
<Typography variant="h6">
Processing your payment...
</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ py: 4 }}>
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
<Button
variant="contained"
onClick={() => navigate('/cart')}
>
Return to Cart
</Button>
</Box>
);
}
return (
<Box sx={{ py: 4 }}>
<Paper sx={{ p: 4, maxWidth: 600, mx: 'auto', textAlign: 'center' }}>
<CheckCircleIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
<Typography variant="h4" component="h1" gutterBottom>
Payment Successful!
</Typography>
<Typography variant="body1" paragraph>
Thank you for your purchase. Your order has been completed and a confirmation email will be sent shortly.
</Typography>
<Divider sx={{ my: 3 }} />
<Box sx={{ textAlign: 'left', mb: 3 }}>
<Typography variant="subtitle1">
Order Reference: {orderDetails?.orderId.substring(0, 8)}...
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button
variant="outlined"
onClick={() => navigate('/products')}
>
Continue Shopping
</Button>
<Button
variant="contained"
onClick={() => navigate('/account/orders')}
>
View Orders
</Button>
</Box>
</Paper>
</Box>
);
};
export default PaymentSuccessPage;