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",
|
"express": "^4.18.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
|
"stripe": "^12.0.0",
|
||||||
"nodemailer": "^6.9.1",
|
"nodemailer": "^6.9.1",
|
||||||
"pg": "^8.10.0",
|
"pg": "^8.10.0",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,14 @@ const config = {
|
||||||
reply: process.env.EMAIL_REPLY || 'noreply@2many.ca'
|
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 configuration (domain and protocol based on environment)
|
||||||
site: {
|
site: {
|
||||||
domain: process.env.ENVIRONMENT === 'prod' ? 'rocks.2many.ca' : 'localhost:3000',
|
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;
|
module.exports = config;
|
||||||
|
|
@ -8,9 +8,11 @@ const { query, pool } = require('./db')
|
||||||
const authMiddleware = require('./middleware/auth');
|
const authMiddleware = require('./middleware/auth');
|
||||||
const adminAuthMiddleware = require('./middleware/adminAuth');
|
const adminAuthMiddleware = require('./middleware/adminAuth');
|
||||||
const settingsAdminRoutes = require('./routes/settingsAdmin');
|
const settingsAdminRoutes = require('./routes/settingsAdmin');
|
||||||
|
const SystemSettings = require('./models/SystemSettings');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// routes
|
// routes
|
||||||
|
const stripePaymentRoutes = require('./routes/stripePayment');
|
||||||
const productRoutes = require('./routes/products');
|
const productRoutes = require('./routes/products');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const cartRoutes = require('./routes/cart');
|
const cartRoutes = require('./routes/cart');
|
||||||
|
|
@ -76,9 +78,24 @@ const upload = multer({
|
||||||
});
|
});
|
||||||
|
|
||||||
pool.connect()
|
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));
|
.catch(err => console.error('Database connection error:', err));
|
||||||
|
|
||||||
|
|
||||||
// Handle SSL proxy headers
|
// Handle SSL proxy headers
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
// Trust X-Forwarded-Proto header from Cloudflare
|
// Trust X-Forwarded-Proto header from Cloudflare
|
||||||
|
|
@ -220,7 +237,10 @@ app.delete('/api/image/product/:filename', adminAuthMiddleware(pool, query), (re
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Use routes
|
// 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/admin/settings', settingsAdminRoutes(pool, query, adminAuthMiddleware(pool, query)));
|
||||||
app.use('/api/products', productRoutes(pool, query));
|
app.use('/api/products', productRoutes(pool, query));
|
||||||
app.use('/api/auth', authRoutes(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) => {
|
router.post('/checkout', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { userId, shippingAddress } = req.body;
|
const { userId, shippingAddress } = req.body;
|
||||||
|
|
||||||
if (req.user.id !== userId) {
|
if (req.user.id !== userId) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: true,
|
error: true,
|
||||||
message: 'You can only checkout your own cart'
|
message: 'You can only checkout your own cart'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get cart
|
// Get cart
|
||||||
const cartResult = await query(
|
const cartResult = await query(
|
||||||
'SELECT * FROM carts WHERE user_id = $1',
|
'SELECT * FROM carts WHERE user_id = $1',
|
||||||
|
|
@ -420,7 +421,16 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
|
|
||||||
// Get cart items
|
// Get cart items
|
||||||
const cartItemsResult = await query(
|
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
|
FROM cart_items ci
|
||||||
JOIN products p ON ci.product_id = p.id
|
JOIN products p ON ci.product_id = p.id
|
||||||
WHERE ci.cart_id = $1`,
|
WHERE ci.cart_id = $1`,
|
||||||
|
|
@ -448,8 +458,8 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
// Create order
|
// Create order
|
||||||
const orderId = uuidv4();
|
const orderId = uuidv4();
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO orders (id, user_id, status, total_amount, shipping_address) VALUES ($1, $2, $3, $4, $5)',
|
'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]
|
[orderId, userId, 'pending', total, shippingAddress, false]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create order items
|
// Create order items
|
||||||
|
|
@ -458,8 +468,85 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
'INSERT INTO order_items (id, order_id, product_id, quantity, price_at_purchase) VALUES ($1, $2, $3, $4, $5)',
|
'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]
|
[uuidv4(), orderId, item.product_id, item.quantity, item.price]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
// Send back cart items for Stripe checkout
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
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
|
// Update product stock
|
||||||
|
for (const item of cartItemsResult.rows) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2',
|
'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2',
|
||||||
[item.quantity, item.product_id]
|
[item.quantity, item.product_id]
|
||||||
|
|
@ -471,12 +558,13 @@ module.exports = (pool, query, authMiddleware) => {
|
||||||
'DELETE FROM cart_items WHERE cart_id = $1',
|
'DELETE FROM cart_items WHERE cart_id = $1',
|
||||||
[cartId]
|
[cartId]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Order created successfully',
|
message: 'Order completed successfully',
|
||||||
orderId
|
orderId
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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'),
|
('site_environment', NULL, 'site'),
|
||||||
|
|
||||||
-- Payment Settings
|
-- Payment Settings
|
||||||
('stripe_public_key', NULL, 'payment'),
|
|
||||||
('stripe_secret_key', NULL, 'payment'),
|
|
||||||
('currency', 'CAD', 'payment'),
|
('currency', 'CAD', 'payment'),
|
||||||
('tax_rate', '0', '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/
|
Rocks/
|
||||||
├── public/
|
├── .git/ # Git repository
|
||||||
├── node_modules/
|
├── .env # Environment configuration
|
||||||
├── src/
|
├── .gitignore # Git ignore file
|
||||||
│ ├── routes/
|
├── README.md # Project documentation
|
||||||
│ │ ├── auth.js
|
├── Dockerfile # Main Dockerfile
|
||||||
│ │ ├── products.js
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
│ │ ├── productAdminImages.js
|
├── nginx.conf # Nginx configuration
|
||||||
│ │ ├── images.js
|
├── setup-frontend.sh # Frontend setup script
|
||||||
│ │ ├── productAdmin.js
|
├── package.json # Project dependencies
|
||||||
│ │ └── cart.js
|
├── index.html # Main HTML entry point
|
||||||
│ ├── middleware/
|
├── frontend/
|
||||||
│ │ ├── upload.js
|
│ ├── node_modules/ # Node.js dependencies
|
||||||
│ │ ├── auth.js
|
│ ├── public/ # Static public assets
|
||||||
│ │ └── adminAuth.js
|
│ └── src/
|
||||||
│ ├── db/
|
│ ├── assets/ # Static assets
|
||||||
│ │ └── 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/
|
│ ├── components/
|
||||||
│ │ ├── ImageUploader.jsx
|
│ │ ├── EmailDialog.jsx # Email dialog component
|
||||||
│ │ ├── ProductImage.jsx
|
│ │ ├── Footer.jsx # Footer component
|
||||||
│ │ ├── Footer.jsx
|
│ │ ├── ImageUploader.jsx # Image upload component
|
||||||
│ │ ├── ProtectedRoute.jsx
|
│ │ ├── Notifications.jsx # Notifications component
|
||||||
│ │ └── Notifications.jsx
|
│ │ ├── ProductImage.jsx # Product image component
|
||||||
│ ├── services/
|
│ │ └── ProtectedRoute.jsx # Auth route protection
|
||||||
│ │ ├── 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/
|
│ ├── features/
|
||||||
│ │ ├── ui/
|
│ │ ├── ui/
|
||||||
│ │ │ └── uiSlice.js
|
│ │ │ └── uiSlice.js # UI state management
|
||||||
│ │ ├── cart/
|
│ │ ├── cart/
|
||||||
│ │ │ └── cartSlice.js
|
│ │ │ └── cartSlice.js # Cart state management
|
||||||
│ │ ├── auth/
|
│ │ ├── auth/
|
||||||
│ │ │ └── authSlice.js
|
│ │ │ └── authSlice.js # Auth state management
|
||||||
│ │ └── store/
|
│ │ └── store/
|
||||||
│ │ └── index.js
|
│ │ └── index.js # Redux store configuration
|
||||||
│ ├── assets/
|
│ ├── hooks/
|
||||||
│ ├── App.jsx
|
│ │ ├── reduxHooks.js # Redux related hooks
|
||||||
│ ├── main.jsx
|
│ │ ├── apiHooks.js # API related hooks
|
||||||
│ └── config.js
|
│ │ └── settingsAdminHooks.js # Admin settings hooks
|
||||||
├── package.json
|
│ ├── layouts/
|
||||||
├── package-lock.json
|
│ │ ├── AdminLayout.jsx # Admin area layout
|
||||||
├── vite.config.js
|
│ │ ├── MainLayout.jsx # Main site layout
|
||||||
├── Dockerfile
|
│ │ └── AuthLayout.jsx # Authentication layout
|
||||||
├── nginx.conf
|
│ ├── pages/
|
||||||
├── index.html
|
│ │ ├── Admin/
|
||||||
├── README.md
|
│ │ │ ├── DashboardPage.jsx # Admin dashboard
|
||||||
├── .env
|
│ │ │ ├── ProductsPage.jsx # Products management
|
||||||
├── setup-frontend.sh
|
│ │ │ ├── ProductEditPage.jsx # Product editing
|
||||||
└── docker-compose.yml
|
│ │ │ ├── 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/icons-material": "^5.14.19",
|
||||||
"@mui/material": "^5.14.19",
|
"@mui/material": "^5.14.19",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@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": "^5.12.2",
|
||||||
"@tanstack/react-query-devtools": "^5.12.2",
|
"@tanstack/react-query-devtools": "^5.12.2",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
|
@ -1841,6 +1843,24 @@
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
"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": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.74.4",
|
"version": "5.74.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.74.4.tgz",
|
"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/icons-material": "^5.14.19",
|
||||||
"@mui/material": "^5.14.19",
|
"@mui/material": "^5.14.19",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@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": "^5.12.2",
|
||||||
"@tanstack/react-query-devtools": "^5.12.2",
|
"@tanstack/react-query-devtools": "^5.12.2",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Suspense, lazy } from 'react';
|
||||||
import { CircularProgress, Box } from '@mui/material';
|
import { CircularProgress, Box } from '@mui/material';
|
||||||
import Notifications from './components/Notifications';
|
import Notifications from './components/Notifications';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
import { StripeProvider } from './context/StripeContext';
|
||||||
|
|
||||||
// Layouts
|
// Layouts
|
||||||
import MainLayout from './layouts/MainLayout';
|
import MainLayout from './layouts/MainLayout';
|
||||||
|
|
@ -15,6 +16,8 @@ const ProductsPage = lazy(() => import('./pages/ProductsPage'));
|
||||||
const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
|
const ProductDetailPage = lazy(() => import('./pages/ProductDetailPage'));
|
||||||
const CartPage = lazy(() => import('./pages/CartPage'));
|
const CartPage = lazy(() => import('./pages/CartPage'));
|
||||||
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
|
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 LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
||||||
const VerifyPage = lazy(() => import('./pages/VerifyPage'));
|
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 AdminOrdersPage = lazy(() => import('./pages/Admin/OrdersPage'));
|
||||||
const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage'));
|
const AdminSettingsPage = lazy(() => import('./pages/Admin/SettingsPage'));
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||||
|
|
||||||
// Loading component for suspense fallback
|
// Loading component for suspense fallback
|
||||||
const LoadingComponent = () => (
|
const LoadingComponent = () => (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
|
@ -35,6 +39,7 @@ const LoadingComponent = () => (
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<StripeProvider>
|
||||||
<Suspense fallback={<LoadingComponent />}>
|
<Suspense fallback={<LoadingComponent />}>
|
||||||
<Notifications />
|
<Notifications />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -53,6 +58,17 @@ function App() {
|
||||||
<CheckoutPage />
|
<CheckoutPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
{/* Payment success and cancel routes */}
|
||||||
|
<Route path="checkout/success" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PaymentSuccessPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="checkout/cancel" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PaymentCancelPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Auth routes with AuthLayout */}
|
{/* Auth routes with AuthLayout */}
|
||||||
|
|
@ -84,6 +100,7 @@ function App() {
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</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,8 +258,103 @@ const AdminSettingsPage = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</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}>
|
<Grid container spacing={3}>
|
||||||
{settingsData[category].map((setting) => (
|
{settingsData[category].map((setting) => {
|
||||||
|
if(setting.key.toUpperCase().includes("STRIPE"))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return (
|
||||||
<Grid item xs={12} md={6} key={setting.key}>
|
<Grid item xs={12} md={6} key={setting.key}>
|
||||||
<Card variant="outlined">
|
<Card variant="outlined">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -329,7 +424,9 @@ const AdminSettingsPage = () => {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
)
|
||||||
|
}
|
||||||
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||||
import { useAuth, useCart } from '../hooks/reduxHooks';
|
import { useAuth, useCart } from '../hooks/reduxHooks';
|
||||||
import { useCheckout } from '../hooks/apiHooks';
|
import { useCheckout } from '../hooks/apiHooks';
|
||||||
|
import { useStripe, StripeElementsProvider } from '../context/StripeContext';
|
||||||
|
import StripePaymentForm from '../components/StripePaymentForm';
|
||||||
|
|
||||||
// Checkout steps
|
// Checkout steps
|
||||||
const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation'];
|
const steps = ['Shipping Address', 'Review Order', 'Payment', 'Confirmation'];
|
||||||
|
|
@ -30,9 +32,14 @@ const CheckoutPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { items, total, itemCount } = useCart();
|
const { items, total, itemCount } = useCart();
|
||||||
const checkout = useCheckout();
|
const checkout = useCheckout();
|
||||||
|
const { createCheckoutSession, isLoading: isStripeLoading } = useStripe();
|
||||||
|
|
||||||
// State for checkout steps
|
// State for checkout steps
|
||||||
const [activeStep, setActiveStep] = useState(0);
|
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
|
// State for form data
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
|
|
@ -107,29 +114,61 @@ const CheckoutPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle place order
|
// Handle place order
|
||||||
const handlePlaceOrder = () => {
|
const handlePlaceOrder = async () => {
|
||||||
if (!user || !items || items.length === 0) {
|
if (!user || !items || items.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format shipping address for API
|
setIsProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Format shipping address
|
||||||
const shippingAddress = `${formData.firstName} ${formData.lastName}
|
const shippingAddress = `${formData.firstName} ${formData.lastName}
|
||||||
${formData.address}
|
${formData.address}
|
||||||
${formData.city}, ${formData.state} ${formData.zipCode}
|
${formData.city}, ${formData.state} ${formData.zipCode}
|
||||||
${formData.country}
|
${formData.country}
|
||||||
${formData.email}`;
|
${formData.email}`;
|
||||||
|
|
||||||
checkout.mutate({
|
// Call the checkout API to create the order
|
||||||
|
const orderResponse = await checkout.mutateAsync({
|
||||||
userId: user,
|
userId: user,
|
||||||
shippingAddress
|
shippingAddress
|
||||||
}, {
|
|
||||||
onSuccess: () => {
|
|
||||||
// Move to confirmation step
|
|
||||||
setActiveStep(3);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 no items in cart, redirect to cart page
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -330,24 +369,26 @@ ${formData.email}`;
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
// Placeholder for payment (in a real app, this would have a payment form)
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
|
{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 }}>
|
<Alert severity="info" sx={{ mb: 3 }}>
|
||||||
This is a demo application. No actual payment will be processed.
|
You will be redirected to our secure payment processor.
|
||||||
</Alert>
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom>
|
{error && (
|
||||||
Payment Method
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
</Typography>
|
{error}
|
||||||
|
</Alert>
|
||||||
<Typography paragraph>
|
)}
|
||||||
For this demo, we'll simulate a successful payment.
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Typography paragraph>
|
|
||||||
Total to pay: ${total.toFixed(2)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
case 3:
|
case 3:
|
||||||
|
|
@ -362,7 +403,7 @@ ${formData.email}`;
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Typography paragraph>
|
<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>
|
||||||
|
|
||||||
<Typography paragraph>
|
<Typography paragraph>
|
||||||
|
|
@ -402,28 +443,28 @@ ${formData.email}`;
|
||||||
{getStepContent(activeStep)}
|
{getStepContent(activeStep)}
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
|
||||||
{activeStep !== 0 && activeStep !== 3 && (
|
{activeStep !== 0 && activeStep !== 3 && !isProcessing && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
sx={{ mr: 1 }}
|
sx={{ mr: 1 }}
|
||||||
disabled={checkout.isLoading}
|
disabled={checkout.isLoading || isProcessing}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep !== 3 ? (
|
{activeStep !== 2 && activeStep !== 3 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={checkout.isLoading}
|
disabled={checkout.isLoading || isProcessing}
|
||||||
>
|
>
|
||||||
{activeStep === steps.length - 2 ? 'Place Order' : 'Next'}
|
{activeStep === steps.length - 2 ? 'Place Order' : 'Next'}
|
||||||
{checkout.isLoading && (
|
{(checkout.isLoading || isProcessing) && (
|
||||||
<CircularProgress size={24} sx={{ ml: 1 }} />
|
<CircularProgress size={24} sx={{ ml: 1 }} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : activeStep === 3 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
|
|
@ -431,7 +472,7 @@ ${formData.email}`;
|
||||||
>
|
>
|
||||||
Return to Home
|
Return to Home
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</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