Stripe integration
This commit is contained in:
parent
c40852703d
commit
c0b9c6e4f1
17 changed files with 1299 additions and 269 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
198
backend/src/routes/stripePayment.js
Normal file
198
backend/src/routes/stripePayment.js
Normal 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;
|
||||
};
|
||||
|
|
@ -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
22
db/init/10-payment.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
20
frontend/package-lock.json
generated
20
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
98
frontend/src/components/StripePaymentForm.jsx
Normal file
98
frontend/src/components/StripePaymentForm.jsx
Normal 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;
|
||||
151
frontend/src/context/StripeContext.jsx
Normal file
151
frontend/src/context/StripeContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
53
frontend/src/pages/PaymentCancelPage.jsx
Normal file
53
frontend/src/pages/PaymentCancelPage.jsx
Normal 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;
|
||||
147
frontend/src/pages/PaymentSuccessPage.jsx
Normal file
147
frontend/src/pages/PaymentSuccessPage.jsx
Normal 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;
|
||||
Loading…
Reference in a new issue