diff --git a/backend/package-lock.json b/backend/package-lock.json index 5bf340d..3ddfe7e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "pg": "^8.16.3", "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", + "stripe": "^18.4.0", "uuid": "^11.1.0" }, "devDependencies": { @@ -2201,6 +2202,26 @@ "node": ">=8" } }, + "node_modules/stripe": { + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.4.0.tgz", + "integrity": "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/backend/package.json b/backend/package.json index 592960a..41403c5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "pg": "^8.16.3", "sequelize": "^6.37.7", "sequelize-cli": "^6.6.3", + "stripe": "^18.4.0", "uuid": "^11.1.0" }, "devDependencies": { diff --git a/backend/routes/rentals.js b/backend/routes/rentals.js index 2b3c8a7..8e9fda3 100644 --- a/backend/routes/rentals.js +++ b/backend/routes/rentals.js @@ -1,7 +1,7 @@ -const express = require('express'); -const { Op } = require('sequelize'); -const { Rental, Item, User } = require('../models'); // Import from models/index.js to get models with associations -const { authenticateToken } = require('../middleware/auth'); +const express = require("express"); +const { Op } = require("sequelize"); +const { Rental, Item, User } = require("../models"); // Import from models/index.js to get models with associations +const { authenticateToken } = require("../middleware/auth"); const router = express.Router(); // Helper function to check and update review visibility @@ -31,7 +31,8 @@ const checkAndUpdateReviewVisibility = async (rental) => { // Check renter review visibility (10-minute rule) if (rental.renterReviewSubmittedAt && !rental.renterReviewVisible) { - const timeSinceSubmission = now - new Date(rental.renterReviewSubmittedAt); + const timeSinceSubmission = + now - new Date(rental.renterReviewSubmittedAt); if (timeSinceSubmission >= tenMinutesInMs) { updates.renterReviewVisible = true; needsUpdate = true; @@ -46,79 +47,98 @@ const checkAndUpdateReviewVisibility = async (rental) => { return rental; }; -router.get('/my-rentals', authenticateToken, async (req, res) => { +router.get("/my-rentals", authenticateToken, async (req, res) => { try { const rentals = await Rental.findAll({ where: { renterId: req.user.id }, // Remove explicit attributes to let Sequelize handle missing columns gracefully include: [ - { model: Item, as: 'item' }, - { model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] } + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, ], - order: [['createdAt', 'DESC']] + order: [["createdAt", "DESC"]], }); - console.log('My-rentals data:', rentals.length > 0 ? rentals[0].toJSON() : 'No rentals'); res.json(rentals); } catch (error) { - console.error('Error in my-rentals route:', error); + console.error("Error in my-rentals route:", error); res.status(500).json({ error: error.message }); } }); -router.get('/my-listings', authenticateToken, async (req, res) => { +router.get("/my-listings", authenticateToken, async (req, res) => { try { const rentals = await Rental.findAll({ where: { ownerId: req.user.id }, // Remove explicit attributes to let Sequelize handle missing columns gracefully include: [ - { model: Item, as: 'item' }, - { model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] } + { model: Item, as: "item" }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName"], + }, ], - order: [['createdAt', 'DESC']] + order: [["createdAt", "DESC"]], }); - console.log('My-listings rentals:', rentals.length > 0 ? rentals[0].toJSON() : 'No rentals'); res.json(rentals); } catch (error) { - console.error('Error in my-listings route:', error); + console.error("Error in my-listings route:", error); res.status(500).json({ error: error.message }); } }); -router.post('/', authenticateToken, async (req, res) => { +router.post("/", authenticateToken, async (req, res) => { try { - const { itemId, startDate, endDate, startTime, endTime, deliveryMethod, deliveryAddress, notes } = req.body; + const { + itemId, + startDate, + endDate, + startTime, + endTime, + deliveryMethod, + deliveryAddress, + notes, + } = req.body; const item = await Item.findByPk(itemId); if (!item) { - return res.status(404).json({ error: 'Item not found' }); + return res.status(404).json({ error: "Item not found" }); } if (!item.availability) { - return res.status(400).json({ error: 'Item is not available' }); + return res.status(400).json({ error: "Item is not available" }); } const overlappingRental = await Rental.findOne({ where: { itemId, - status: { [Op.in]: ['confirmed', 'active'] }, + status: { [Op.in]: ["confirmed", "active"] }, [Op.or]: [ { - startDate: { [Op.between]: [startDate, endDate] } + startDate: { [Op.between]: [startDate, endDate] }, }, { - endDate: { [Op.between]: [startDate, endDate] } - } - ] - } + endDate: { [Op.between]: [startDate, endDate] }, + }, + ], + }, }); if (overlappingRental) { - return res.status(400).json({ error: 'Item is already booked for these dates' }); + return res + .status(400) + .json({ error: "Item is already booked for these dates" }); } - const rentalDays = Math.ceil((new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24)); + const rentalDays = Math.ceil( + (new Date(endDate) - new Date(startDate)) / (1000 * 60 * 60 * 24) + ); const totalAmount = rentalDays * (item.pricePerDay || 0); const rental = await Rental.create({ @@ -132,15 +152,23 @@ router.post('/', authenticateToken, async (req, res) => { totalAmount, deliveryMethod, deliveryAddress, - notes + notes, }); const rentalWithDetails = await Rental.findByPk(rental.id, { include: [ - { model: Item, as: 'item' }, - { model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }, - { model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] } - ] + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], }); res.status(201).json(rentalWithDetails); @@ -149,27 +177,35 @@ router.post('/', authenticateToken, async (req, res) => { } }); -router.put('/:id/status', authenticateToken, async (req, res) => { +router.put("/:id/status", authenticateToken, async (req, res) => { try { const { status } = req.body; const rental = await Rental.findByPk(req.params.id); if (!rental) { - return res.status(404).json({ error: 'Rental not found' }); + return res.status(404).json({ error: "Rental not found" }); } if (rental.ownerId !== req.user.id && rental.renterId !== req.user.id) { - return res.status(403).json({ error: 'Unauthorized' }); + return res.status(403).json({ error: "Unauthorized" }); } await rental.update({ status }); const updatedRental = await Rental.findByPk(rental.id, { include: [ - { model: Item, as: 'item' }, - { model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }, - { model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] } - ] + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], }); res.json(updatedRental); @@ -179,33 +215,35 @@ router.put('/:id/status', authenticateToken, async (req, res) => { }); // Owner reviews renter -router.post('/:id/review-renter', authenticateToken, async (req, res) => { +router.post("/:id/review-renter", authenticateToken, async (req, res) => { try { const { rating, review, privateMessage } = req.body; const rental = await Rental.findByPk(req.params.id); if (!rental) { - return res.status(404).json({ error: 'Rental not found' }); + return res.status(404).json({ error: "Rental not found" }); } if (rental.ownerId !== req.user.id) { - return res.status(403).json({ error: 'Only owners can review renters' }); + return res.status(403).json({ error: "Only owners can review renters" }); } - if (rental.status !== 'completed') { - return res.status(400).json({ error: 'Can only review completed rentals' }); + if (rental.status !== "completed") { + return res + .status(400) + .json({ error: "Can only review completed rentals" }); } if (rental.renterReviewSubmittedAt) { - return res.status(400).json({ error: 'Renter review already submitted' }); + return res.status(400).json({ error: "Renter review already submitted" }); } // Submit the review and private message - await rental.update({ - renterRating: rating, + await rental.update({ + renterRating: rating, renterReview: review, renterReviewSubmittedAt: new Date(), - renterPrivateMessage: privateMessage + renterPrivateMessage: privateMessage, }); // Check and update visibility @@ -213,7 +251,7 @@ router.post('/:id/review-renter', authenticateToken, async (req, res) => { res.json({ success: true, - reviewVisible: updatedRental.renterReviewVisible + reviewVisible: updatedRental.renterReviewVisible, }); } catch (error) { res.status(500).json({ error: error.message }); @@ -221,33 +259,35 @@ router.post('/:id/review-renter', authenticateToken, async (req, res) => { }); // Renter reviews item -router.post('/:id/review-item', authenticateToken, async (req, res) => { +router.post("/:id/review-item", authenticateToken, async (req, res) => { try { const { rating, review, privateMessage } = req.body; const rental = await Rental.findByPk(req.params.id); if (!rental) { - return res.status(404).json({ error: 'Rental not found' }); + return res.status(404).json({ error: "Rental not found" }); } if (rental.renterId !== req.user.id) { - return res.status(403).json({ error: 'Only renters can review items' }); + return res.status(403).json({ error: "Only renters can review items" }); } - if (rental.status !== 'completed') { - return res.status(400).json({ error: 'Can only review completed rentals' }); + if (rental.status !== "completed") { + return res + .status(400) + .json({ error: "Can only review completed rentals" }); } if (rental.itemReviewSubmittedAt) { - return res.status(400).json({ error: 'Item review already submitted' }); + return res.status(400).json({ error: "Item review already submitted" }); } // Submit the review and private message - await rental.update({ - itemRating: rating, + await rental.update({ + itemRating: rating, itemReview: review, itemReviewSubmittedAt: new Date(), - itemPrivateMessage: privateMessage + itemPrivateMessage: privateMessage, }); // Check and update visibility @@ -255,7 +295,7 @@ router.post('/:id/review-item', authenticateToken, async (req, res) => { res.json({ success: true, - reviewVisible: updatedRental.itemReviewVisible + reviewVisible: updatedRental.itemReviewVisible, }); } catch (error) { res.status(500).json({ error: error.message }); @@ -263,31 +303,43 @@ router.post('/:id/review-item', authenticateToken, async (req, res) => { }); // Mark rental as completed (owner only) -router.post('/:id/mark-completed', authenticateToken, async (req, res) => { +router.post("/:id/mark-completed", authenticateToken, async (req, res) => { try { - console.log('Mark completed endpoint hit for rental ID:', req.params.id); + console.log("Mark completed endpoint hit for rental ID:", req.params.id); const rental = await Rental.findByPk(req.params.id); if (!rental) { - return res.status(404).json({ error: 'Rental not found' }); + return res.status(404).json({ error: "Rental not found" }); } if (rental.ownerId !== req.user.id) { - return res.status(403).json({ error: 'Only owners can mark rentals as completed' }); + return res + .status(403) + .json({ error: "Only owners can mark rentals as completed" }); } - if (!['active', 'confirmed'].includes(rental.status)) { - return res.status(400).json({ error: 'Can only mark active or confirmed rentals as completed' }); + if (!["active", "confirmed"].includes(rental.status)) { + return res.status(400).json({ + error: "Can only mark active or confirmed rentals as completed", + }); } - await rental.update({ status: 'completed' }); + await rental.update({ status: "completed" }); const updatedRental = await Rental.findByPk(rental.id, { include: [ - { model: Item, as: 'item' }, - { model: User, as: 'owner', attributes: ['id', 'username', 'firstName', 'lastName'] }, - { model: User, as: 'renter', attributes: ['id', 'username', 'firstName', 'lastName'] } - ] + { model: Item, as: "item" }, + { + model: User, + as: "owner", + attributes: ["id", "username", "firstName", "lastName"], + }, + { + model: User, + as: "renter", + attributes: ["id", "username", "firstName", "lastName"], + }, + ], }); res.json(updatedRental); @@ -297,21 +349,23 @@ router.post('/:id/mark-completed', authenticateToken, async (req, res) => { }); // Legacy review endpoint (for backward compatibility) -router.post('/:id/review', authenticateToken, async (req, res) => { +router.post("/:id/review", authenticateToken, async (req, res) => { try { const { rating, review } = req.body; const rental = await Rental.findByPk(req.params.id); if (!rental) { - return res.status(404).json({ error: 'Rental not found' }); + return res.status(404).json({ error: "Rental not found" }); } if (rental.renterId !== req.user.id) { - return res.status(403).json({ error: 'Only renters can leave reviews' }); + return res.status(403).json({ error: "Only renters can leave reviews" }); } - if (rental.status !== 'completed') { - return res.status(400).json({ error: 'Can only review completed rentals' }); + if (rental.status !== "completed") { + return res + .status(400) + .json({ error: "Can only review completed rentals" }); } await rental.update({ rating, review }); @@ -322,4 +376,4 @@ router.post('/:id/review', authenticateToken, async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/routes/stripe.js b/backend/routes/stripe.js new file mode 100644 index 0000000..bc4b464 --- /dev/null +++ b/backend/routes/stripe.js @@ -0,0 +1,206 @@ +const express = require("express"); +const { authenticateToken } = require("../middleware/auth"); +const { User } = require("../models"); +const { Rental, Item } = require("../models"); +const StripeService = require("../services/stripeService"); +const router = express.Router(); +const platformFee = 0.1; + +router.post("/create-checkout-session", async (req, res) => { + try { + const { itemName, total, return_url } = req.body; + + if (!itemName) { + return res.status(400).json({ error: "No item name found" }); + } + if (total == null || total === undefined) { + return res.status(400).json({ error: "No total found" }); + } + if (!return_url) { + return res.status(400).json({ error: "No return_url found" }); + } + + const session = await StripeService.createCheckoutSession({ + item_name: itemName, + total: total, + return_url: return_url, + }); + + res.json({ clientSecret: session.client_secret }); + } catch (error) { + console.error("Error creating checkout session:", error); + res.status(500).json({ error: error.message }); + } +}); + +// Get checkout session status +router.get("/checkout-session/:sessionId", async (req, res) => { + try { + const { sessionId } = req.params; + + const session = await StripeService.getCheckoutSession(sessionId); + + res.json({ + status: session.status, + payment_status: session.payment_status, + customer_email: session.customer_details?.email, + }); + } catch (error) { + console.error("Error retrieving checkout session:", error); + res.status(500).json({ error: error.message }); + } +}); + +// // Create connected account +// router.post("/accounts", authenticateToken, async (req, res) => { +// try { +// const user = await User.findByPk(req.user.id); + +// if (!user) { +// return res.status(404).json({ error: "User not found" }); +// } + +// // Check if user already has a connected account +// if (user.stripeConnectedAccountId) { +// return res +// .status(400) +// .json({ error: "User already has a connected account" }); +// } + +// // Create connected account +// const account = await StripeService.createConnectedAccount({ +// email: user.email, +// country: "US", // You may want to make this configurable +// }); + +// // Update user with account ID +// await user.update({ +// stripeConnectedAccountId: account.id, +// }); + +// res.json({ +// stripeConnectedAccountId: account.id, +// success: true, +// }); +// } catch (error) { +// console.error("Error creating connected account:", error); +// res.status(500).json({ error: error.message }); +// } +// }); + +// // Generate onboarding link +// router.post("/account-links", authenticateToken, async (req, res) => { +// try { +// const user = await User.findByPk(req.user.id); + +// if (!user || !user.stripeConnectedAccountId) { +// return res.status(400).json({ error: "No connected account found" }); +// } + +// const { refreshUrl, returnUrl } = req.body; + +// if (!refreshUrl || !returnUrl) { +// return res +// .status(400) +// .json({ error: "refreshUrl and returnUrl are required" }); +// } + +// const accountLink = await StripeService.createAccountLink( +// user.stripeConnectedAccountId, +// refreshUrl, +// returnUrl +// ); + +// res.json({ +// url: accountLink.url, +// expiresAt: accountLink.expires_at, +// }); +// } catch (error) { +// console.error("Error creating account link:", error); +// res.status(500).json({ error: error.message }); +// } +// }); + +// // Get account status +// router.get("/account-status", authenticateToken, async (req, res) => { +// try { +// const user = await User.findByPk(req.user.id); + +// if (!user || !user.stripeConnectedAccountId) { +// return res.status(400).json({ error: "No connected account found" }); +// } + +// const accountStatus = await StripeService.getAccountStatus( +// user.stripeConnectedAccountId +// ); + +// res.json({ +// accountId: accountStatus.id, +// detailsSubmitted: accountStatus.details_submitted, +// payoutsEnabled: accountStatus.payouts_enabled, +// capabilities: accountStatus.capabilities, +// requirements: accountStatus.requirements, +// }); +// } catch (error) { +// console.error("Error getting account status:", error); +// res.status(500).json({ error: error.message }); +// } +// }); + +// // Create payment intent for rental +// router.post("/payment-intents", authenticateToken, async (req, res) => { +// try { +// const { rentalId, amount } = req.body; + +// if (!rentalId || !amount) { +// return res +// .status(400) +// .json({ error: "rentalId and amount are required" }); +// } + +// // Get rental details to find owner's connected account +// const rental = await Rental.findByPk(rentalId, { +// include: [{ model: Item, as: "item" }], +// }); + +// if (!rental) { +// return res.status(404).json({ error: "Rental not found" }); +// } + +// if (rental.ownerId !== req.user.id) { +// return res.status(403).json({ error: "Unauthorized" }); +// } + +// // Get owner's connected account +// const owner = await User.findByPk(rental.ownerId); +// if (!owner || !owner.stripeConnectedAccountId) { +// return res +// .status(400) +// .json({ error: "Owner does not have a connected account" }); +// } + +// const applicationFeeAmount = Math.round(amount * platformFee); + +// const paymentIntent = await StripeService.createPaymentIntent({ +// amount: Math.round(amount * 100), // Convert to cents +// currency: "usd", +// connectedAccountId: owner.stripeConnectedAccountId, +// applicationFeeAmount: applicationFeeAmount * 100, // Convert to cents +// metadata: { +// rentalId: rental.id, +// renterId: rental.renterId, +// ownerId: owner.id, +// }, +// }); + +// res.json({ +// clientSecret: paymentIntent.client_secret, +// paymentIntentId: paymentIntent.id, +// }); +// } catch (error) { +// console.error("Error creating payment intent:", error); +// res.status(500).json({ error: error.message }); +// } +// }); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index f1c0618..914d09c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,6 +19,7 @@ const rentalRoutes = require("./routes/rentals"); const messageRoutes = require("./routes/messages"); const betaRoutes = require("./routes/beta"); const itemRequestRoutes = require("./routes/itemRequests"); +const stripeRoutes = require("./routes/stripe"); const app = express(); @@ -39,6 +40,7 @@ app.use("/api/items", itemRoutes); app.use("/api/rentals", rentalRoutes); app.use("/api/messages", messageRoutes); app.use("/api/item-requests", itemRequestRoutes); +app.use("/api/stripe", stripeRoutes); app.get("/", (req, res) => { res.json({ message: "CommunityRentals.App API is running!" }); diff --git a/backend/services/stripeService.js b/backend/services/stripeService.js new file mode 100644 index 0000000..0ab067d --- /dev/null +++ b/backend/services/stripeService.js @@ -0,0 +1,149 @@ +const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY); + +class StripeService { + static async createCheckoutSession({ item_name, total, return_url }) { + try { + const session = await stripe.checkout.sessions.create({ + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: item_name, + }, + unit_amount: total * 100, + }, + quantity: 1, + }, + ], + mode: "payment", + ui_mode: "embedded", + return_url: return_url, //"https://example.com/checkout/return?session_id={CHECKOUT_SESSION_ID}" + }); + + return session; + } catch (error) { + console.error("Error creating connected account:", error); + throw error; + } + } + + static async getCheckoutSession(sessionId) { + try { + return await stripe.checkout.sessions.retrieve(sessionId); + } catch (error) { + console.error("Error retrieving checkout session:", error); + throw error; + } + } + + // static async createConnectedAccount({ email, country = "US" }) { + // try { + // const account = await stripe.accounts.create({ + // type: "standard", + // email, + // country, + // controller: { + // stripe_dashboard: { + // type: "full", + // }, + // }, + // capabilities: { + // transfers: { requested: true }, + // }, + // }); + + // return account; + // } catch (error) { + // console.error("Error creating connected account:", error); + // throw error; + // } + // } + + // static async createAccountLink(accountId, refreshUrl, returnUrl) { + // try { + // const accountLink = await stripe.accountLinks.create({ + // account: accountId, + // refresh_url: refreshUrl, + // return_url: returnUrl, + // type: "account_onboarding", + // }); + + // return accountLink; + // } catch (error) { + // console.error("Error creating account link:", error); + // throw error; + // } + // } + + // static async getAccountStatus(accountId) { + // try { + // const account = await stripe.accounts.retrieve(accountId); + // return { + // id: account.id, + // details_submitted: account.details_submitted, + // payouts_enabled: account.payouts_enabled, + // capabilities: account.capabilities, + // requirements: account.requirements, + // }; + // } catch (error) { + // console.error("Error retrieving account status:", error); + // throw error; + // } + // } + + // static async createPaymentIntent({ + // amount, + // currency = "usd", + // connectedAccountId, + // applicationFeeAmount, + // metadata = {}, + // }) { + // try { + // const paymentIntent = await stripe.paymentIntents.create({ + // amount, + // currency, + // transfer_data: { + // destination: connectedAccountId, + // }, + // application_fee_amount: applicationFeeAmount, + // metadata, + // automatic_payment_methods: { + // enabled: true, + // }, + // }); + + // return paymentIntent; + // } catch (error) { + // console.error("Error creating payment intent:", error); + // throw error; + // } + // } + + // static async confirmPaymentIntent(paymentIntentId, paymentMethodId) { + // try { + // const paymentIntent = await stripe.paymentIntents.confirm( + // paymentIntentId, + // { + // payment_method: paymentMethodId, + // } + // ); + + // return paymentIntent; + // } catch (error) { + // console.error("Error confirming payment intent:", error); + // throw error; + // } + // } + + // static async retrievePaymentIntent(paymentIntentId) { + // try { + // return await stripe.paymentIntents.retrieve(paymentIntentId); + // } catch (error) { + // console.error("Error retrieving payment intent:", error); + // throw error; + // } + // } +} + +module.exports = StripeService; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e957ce1..7bfaddb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@stripe/react-stripe-js": "^3.3.1", + "@stripe/stripe-js": "^5.2.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -23,6 +25,7 @@ "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", "react-scripts": "5.0.1", + "stripe": "^18.4.0", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, @@ -3009,6 +3012,29 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz", + "integrity": "sha512-urAZek4LrnHWfk4WYXItOiX+6xyxjcn0SkhBDoysXphLkUt92UWCd5+NlomhVqaLo98XiUQGZRiRcL8HOHZ8Jw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz", + "integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -4639,9 +4665,10 @@ } }, "node_modules/axios/node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -5512,15 +5539,16 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -7880,13 +7908,15 @@ } }, "node_modules/form-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", - "integrity": "sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", + "integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.35" }, "engines": { @@ -11071,9 +11101,10 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -14568,6 +14599,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.4.0.tgz", + "integrity": "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ==", + "license": "MIT", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3ae0af2..c702afb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,9 @@ "react-dom": "^19.1.0", "react-router-dom": "^6.30.1", "react-scripts": "5.0.1", + "stripe": "^18.4.0", + "@stripe/react-stripe-js": "^3.3.1", + "@stripe/stripe-js": "^5.2.0", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 305e847..23889f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import ItemRequests from './pages/ItemRequests'; import ItemRequestDetail from './pages/ItemRequestDetail'; import CreateItemRequest from './pages/CreateItemRequest'; import MyRequests from './pages/MyRequests'; +import CheckoutReturn from './components/CheckoutReturn'; import PrivateRoute from './components/PrivateRoute'; import './App.css'; @@ -121,6 +122,14 @@ function App() { } + /> + + + + } /> diff --git a/frontend/src/components/CheckoutReturn.tsx b/frontend/src/components/CheckoutReturn.tsx new file mode 100644 index 0000000..6faec22 --- /dev/null +++ b/frontend/src/components/CheckoutReturn.tsx @@ -0,0 +1,247 @@ +import React, { useState, useEffect, useRef } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { stripeAPI, rentalAPI } from "../services/api"; + +const CheckoutReturn: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState< + "loading" | "success" | "error" | "failed" | "rental_error" + >("loading"); + const [error, setError] = useState(null); + const [processing, setProcessing] = useState(false); + const hasProcessed = useRef(false); + + useEffect(() => { + if (hasProcessed.current) return; + + const sessionId = searchParams.get("session_id"); + + if (!sessionId) { + setStatus("error"); + setError("No session ID found in URL"); + return; + } + + hasProcessed.current = true; + checkSessionStatus(sessionId); + }, [searchParams]); + + const createRental = async () => { + try { + // Get rental data from localStorage (set before payment) + const rentalDataString = localStorage.getItem("pendingRental"); + + if (!rentalDataString) { + console.error("No rental data found in localStorage"); + throw new Error("No rental data found in localStorage"); + } + + const rentalData = JSON.parse(rentalDataString); + + const response = await rentalAPI.createRental(rentalData); + + // Clear the pending rental data + localStorage.removeItem("pendingRental"); + localStorage.removeItem("lastItemId"); + + return response; + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + "Failed to create rental"; + console.error("Throwing error:", errorMessage); + throw new Error(errorMessage); + } + }; + + const checkSessionStatus = async (sessionId: string) => { + try { + setProcessing(true); + + // Get checkout session status + const response = await stripeAPI.getCheckoutSession(sessionId); + + const { status: sessionStatus, payment_status } = response.data; + + if (sessionStatus === "complete" && payment_status === "paid") { + // Payment was successful - now create the rental + try { + const rentalResult = await createRental(); + setStatus("success"); + } catch (rentalError: any) { + // Payment succeeded but rental creation failed + setStatus("rental_error"); + setError(rentalError.message || "Failed to create rental record"); + } + } else if (sessionStatus === "open") { + // Payment was not completed + setStatus("failed"); + setError("Payment was not completed. Please try again."); + } else { + setStatus("error"); + setError("Payment failed or was cancelled."); + } + } catch (error: any) { + setStatus("error"); + setError( + error.response?.data?.error || "Failed to verify payment status" + ); + } finally { + setProcessing(false); + } + }; + + const handleRetry = () => { + // Go back to the item page to try payment again + const itemId = localStorage.getItem("lastItemId"); + if (itemId) { + navigate(`/items/${itemId}`); + } else { + navigate("/"); + } + }; + + const handleRetryRentalCreation = async () => { + setProcessing(true); + try { + await createRental(); + setStatus("success"); + setError(null); + } catch (error: any) { + setError(error.message || "Failed to create rental record"); + } finally { + setProcessing(false); + } + }; + + const handleGoToRentals = () => { + navigate("/my-rentals"); + }; + + if (status === "loading" || processing) { + return ( +
+
+
+
+ Loading... +
+

Processing your payment...

+

+ Please wait while we confirm your payment and set up your rental. +

+
+
+
+ ); + } + + if (status === "success") { + return ( +
+
+
+
+ +

Payment Successful!

+

+ Your rental has been confirmed. You can view the details in your + rentals page. +

+
+ + +
+
+
+
+
+ ); + } + + if (status === "rental_error") { + return ( +
+
+
+
+ +

Payment Successful - Rental Setup Issue

+

+ Your payment was processed successfully, but we encountered an + issue creating your rental record: +
+ {error} +

+
+ + +
+
+
+
+
+ ); + } + + if (status === "failed" || status === "error") { + return ( +
+
+
+
+ +

+ {status === "failed" ? "Payment Incomplete" : "Payment Error"} +

+

+ {error || "There was an issue processing your payment."} +

+
+ + +
+
+
+
+
+ ); + } + + return null; +}; + +export default CheckoutReturn; diff --git a/frontend/src/pages/ItemDetail.tsx b/frontend/src/pages/ItemDetail.tsx index ef4a5d0..e840ead 100644 --- a/frontend/src/pages/ItemDetail.tsx +++ b/frontend/src/pages/ItemDetail.tsx @@ -71,7 +71,7 @@ const ItemDetail: React.FC = () => { startDate: rentalDates.startDate, startTime: rentalDates.startTime, endDate: rentalDates.endDate, - endTime: rentalDates.endTime + endTime: rentalDates.endTime, }); navigate(`/items/${id}/rent?${params.toString()}`); }; @@ -115,75 +115,74 @@ const ItemDetail: React.FC = () => { let availableAfter = "00:00"; let availableBefore = "23:59"; - console.log('generateTimeOptions called with:', { - itemId: item?.id, - selectedDate, - hasItem: !!item - }); - // Determine time constraints only if we have both item and a valid selected date if (item && selectedDate && selectedDate.trim() !== "") { const date = new Date(selectedDate); - const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }).toLowerCase() as - 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday'; - - console.log('Date analysis:', { - selectedDate, - dayName, - specifyTimesPerDay: item.specifyTimesPerDay, - hasWeeklyTimes: !!item.weeklyTimes, - globalAvailableAfter: item.availableAfter, - globalAvailableBefore: item.availableBefore - }); + const dayName = date + .toLocaleDateString("en-US", { weekday: "long" }) + .toLowerCase() as + | "sunday" + | "monday" + | "tuesday" + | "wednesday" + | "thursday" + | "friday" + | "saturday"; // Use day-specific times if available - if (item.specifyTimesPerDay && item.weeklyTimes && item.weeklyTimes[dayName]) { + if ( + item.specifyTimesPerDay && + item.weeklyTimes && + item.weeklyTimes[dayName] + ) { const dayTimes = item.weeklyTimes[dayName]; availableAfter = dayTimes.availableAfter; availableBefore = dayTimes.availableBefore; - console.log('Using day-specific times:', { availableAfter, availableBefore }); + console.log("Using day-specific times:", { + availableAfter, + availableBefore, + }); } // Otherwise use global times else if (item.availableAfter && item.availableBefore) { availableAfter = item.availableAfter; availableBefore = item.availableBefore; - console.log('Using global times:', { availableAfter, availableBefore }); } else { - console.log('No time constraints found, using default 24-hour availability'); + console.log( + "No time constraints found, using default 24-hour availability" + ); } - } else { - console.log('Missing item or selectedDate, using default 24-hour availability'); } for (let hour = 0; hour < 24; hour++) { const time24 = `${hour.toString().padStart(2, "0")}:00`; - + // Ensure consistent format for comparison (normalize to HH:MM) - const normalizedAvailableAfter = availableAfter.length === 5 ? availableAfter : availableAfter + ":00"; - const normalizedAvailableBefore = availableBefore.length === 5 ? availableBefore : availableBefore + ":00"; - + const normalizedAvailableAfter = + availableAfter.length === 5 ? availableAfter : availableAfter + ":00"; + const normalizedAvailableBefore = + availableBefore.length === 5 + ? availableBefore + : availableBefore + ":00"; + // Check if this time is within the available range - if (time24 >= normalizedAvailableAfter && time24 <= normalizedAvailableBefore) { + if ( + time24 >= normalizedAvailableAfter && + time24 <= normalizedAvailableBefore + ) { const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; const period = hour < 12 ? "AM" : "PM"; const time12 = `${hour12}:00 ${period}`; options.push({ value: time24, label: time12 }); } } - - console.log('Time filtering results:', { - availableAfter, - availableBefore, - optionsGenerated: options.length, - firstFewOptions: options.slice(0, 3) - }); - + // If no options are available, return at least one option to prevent empty dropdown if (options.length === 0) { - console.log('No valid time options found, showing Not Available'); + console.log("No valid time options found, showing Not Available"); options.push({ value: "00:00", label: "Not Available" }); } - + return options; }; @@ -197,24 +196,35 @@ const ItemDetail: React.FC = () => { const validateAndAdjustTime = (date: string, currentTime: string) => { if (!date) return currentTime; - + const availableOptions = generateTimeOptions(item, date); if (availableOptions.length === 0) return currentTime; - + // If current time is not in available options, use the first available time - const isCurrentTimeValid = availableOptions.some(option => option.value === currentTime); + const isCurrentTimeValid = availableOptions.some( + (option) => option.value === currentTime + ); return isCurrentTimeValid ? currentTime : availableOptions[0].value; }; - const adjustedStartTime = validateAndAdjustTime(rentalDates.startDate, rentalDates.startTime); - const adjustedEndTime = validateAndAdjustTime(rentalDates.endDate || rentalDates.startDate, rentalDates.endTime); + const adjustedStartTime = validateAndAdjustTime( + rentalDates.startDate, + rentalDates.startTime + ); + const adjustedEndTime = validateAndAdjustTime( + rentalDates.endDate || rentalDates.startDate, + rentalDates.endTime + ); // Update state if times have changed - if (adjustedStartTime !== rentalDates.startTime || adjustedEndTime !== rentalDates.endTime) { - setRentalDates(prev => ({ + if ( + adjustedStartTime !== rentalDates.startTime || + adjustedEndTime !== rentalDates.endTime + ) { + setRentalDates((prev) => ({ ...prev, startTime: adjustedStartTime, - endTime: adjustedEndTime + endTime: adjustedEndTime, })); } }, [item, rentalDates.startDate, rentalDates.endDate]); @@ -473,21 +483,40 @@ const ItemDetail: React.FC = () => { className="form-control" value={rentalDates.startDate} onChange={(e) => - handleDateTimeChange("startDate", e.target.value) + handleDateTimeChange( + "startDate", + e.target.value + ) } min={new Date().toISOString().split("T")[0]} - style={{ flex: '1 1 50%' }} + style={{ flex: "1 1 50%" }} /> { onChange={(e) => handleDateTimeChange("endTime", e.target.value) } - style={{ flex: '1 1 50%' }} - disabled={!!((rentalDates.endDate || rentalDates.startDate) && generateTimeOptions(item, rentalDates.endDate || rentalDates.startDate).every(opt => opt.label === "Not Available"))} + style={{ flex: "1 1 50%" }} + disabled={ + !!( + (rentalDates.endDate || + rentalDates.startDate) && + generateTimeOptions( + item, + rentalDates.endDate || rentalDates.startDate + ).every( + (opt) => opt.label === "Not Available" + ) + ) + } > - {generateTimeOptions(item, rentalDates.endDate || rentalDates.startDate).map((option) => ( + {generateTimeOptions( + item, + rentalDates.endDate || rentalDates.startDate + ).map((option) => ( diff --git a/frontend/src/pages/MyRentals.tsx b/frontend/src/pages/MyRentals.tsx index 625bb34..1371e1c 100644 --- a/frontend/src/pages/MyRentals.tsx +++ b/frontend/src/pages/MyRentals.tsx @@ -45,10 +45,6 @@ const MyRentals: React.FC = () => { const fetchRentals = async () => { try { const response = await rentalAPI.getMyRentals(); - console.log("MyRentals data from backend:", response.data); - if (response.data.length > 0) { - console.log("First rental object:", response.data[0]); - } setRentals(response.data); } catch (err: any) { setError(err.response?.data?.message || "Failed to fetch rentals"); @@ -122,7 +118,9 @@ const MyRentals: React.FC = () => { {renterActiveRentals.length === 0 ? (
No Active Rental Requests
-

You don't have any rental requests at the moment.

+

+ You don't have any rental requests at the moment. +

Browse Items to Rent @@ -130,86 +128,107 @@ const MyRentals: React.FC = () => { ) : (
{renterActiveRentals.map((rental) => ( -
- ) => { - const target = e.target as HTMLElement; - if (!rental.item || target.closest("button")) { - e.preventDefault(); - } - }} +
+ ) => { + const target = e.target as HTMLElement; + if (!rental.item || target.closest("button")) { + e.preventDefault(); + } + }} + > +
-
- {rental.item?.images && rental.item.images[0] && ( - {rental.item.name} - )} -
-
- {rental.item ? rental.item.name : "Item Unavailable"} -
+ {rental.item?.images && rental.item.images[0] && ( + {rental.item.name} + )} +
+
+ {rental.item ? rental.item.name : "Item Unavailable"} +
-
- - {rental.status.charAt(0).toUpperCase() + rental.status.slice(1)} - - {rental.paymentStatus === "paid" && ( - Paid - )} -
- -

- Rental Period: -
- Start: {formatDateTime(rental.startDate, rental.startTime)} -
- End: {formatDateTime(rental.endDate, rental.endTime)} -

- -

- Total: ${rental.totalAmount} -

- - {rental.owner && ( -

- Owner: {rental.owner.firstName} {rental.owner.lastName} -

+
+ + {rental.status.charAt(0).toUpperCase() + + rental.status.slice(1)} + + {rental.paymentStatus === "paid" && ( + Paid )} +
- {rental.renterPrivateMessage && rental.renterReviewVisible && ( +

+ Rental Period: +
+ Start:{" "} + {formatDateTime(rental.startDate, rental.startTime)} +
+ End:{" "} + {formatDateTime(rental.endDate, rental.endTime)} +

+ +

+ Total: ${rental.totalAmount} +

+ + {rental.owner && ( +

+ Owner: {rental.owner.firstName}{" "} + {rental.owner.lastName} +

+ )} + + {rental.renterPrivateMessage && + rental.renterReviewVisible && (
- Private Note from Owner: + + Private + Note from Owner: +
{rental.renterPrivateMessage}
)} - {rental.status === "cancelled" && rental.rejectionReason && ( + {rental.status === "cancelled" && + rental.rejectionReason && (
- Rejection reason: {rental.rejectionReason} + Rejection reason:{" "} + {rental.rejectionReason}
)} -
- {rental.status === "pending" && ( - - )} - {rental.status === "active" && !rental.itemRating && !rental.itemReviewSubmittedAt && ( +
+ {rental.status === "pending" && ( + + )} + {rental.status === "active" && + !rental.itemRating && + !rental.itemReviewSubmittedAt && ( )} - {rental.itemReviewSubmittedAt && !rental.itemReviewVisible && ( + {rental.itemReviewSubmittedAt && + !rental.itemReviewVisible && (
Review Submitted
)} - {rental.itemReviewVisible && rental.itemRating && ( -
- - Review Published ({rental.itemRating}/5) -
- )} - {rental.status === "completed" && rental.rating && !rental.itemRating && ( + {rental.itemReviewVisible && rental.itemRating && ( +
+ + Review Published ({rental.itemRating}/5) +
+ )} + {rental.status === "completed" && + rental.rating && + !rental.itemRating && (
Reviewed ({rental.rating}/5)
)} -
- -
- ))} +
+ +
+ ))}
)} @@ -275,4 +297,4 @@ const MyRentals: React.FC = () => { ); }; -export default MyRentals; \ No newline at end of file +export default MyRentals; diff --git a/frontend/src/pages/RentItem.tsx b/frontend/src/pages/RentItem.tsx index 2d5cc1a..575e6af 100644 --- a/frontend/src/pages/RentItem.tsx +++ b/frontend/src/pages/RentItem.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate, useSearchParams } from "react-router-dom"; import { Item } from "../types"; import { useAuth } from "../contexts/AuthContext"; import { itemAPI, rentalAPI } from "../services/api"; +import StripePaymentForm from "../components/StripePaymentForm"; const RentItem: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -11,16 +12,11 @@ const RentItem: React.FC = () => { const [searchParams] = useSearchParams(); const [item, setItem] = useState(null); const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [formData, setFormData] = useState({ deliveryMethod: "pickup" as "pickup" | "delivery", deliveryAddress: "", - cardNumber: "", - cardExpiry: "", - cardCVC: "", - cardName: "", }); const [manualSelection, setManualSelection] = useState({ @@ -85,6 +81,29 @@ const RentItem: React.FC = () => { calculateTotalCost(); }, [item, manualSelection]); + // Save rental data to localStorage whenever the form is ready + useEffect(() => { + if ( + item && + manualSelection.startDate && + manualSelection.endDate && + totalCost > 0 + ) { + const rentalData = { + itemId: item.id, + startDate: manualSelection.startDate, + endDate: manualSelection.endDate, + startTime: manualSelection.startTime, + endTime: manualSelection.endTime, + totalAmount: totalCost, + deliveryMethod: "pickup", + }; + + localStorage.setItem("pendingRental", JSON.stringify(rentalData)); + localStorage.setItem("lastItemId", item.id); + } + }, [item, manualSelection, totalCost]); + const fetchItem = async () => { try { const response = await itemAPI.getItem(id!); @@ -106,36 +125,11 @@ const RentItem: React.FC = () => { } }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!user || !item) return; - - setSubmitting(true); - setError(null); - - try { - if (!manualSelection.startDate || !manualSelection.endDate) { - setError("Please select a rental period"); - setSubmitting(false); - return; - } - - const rentalData = { - itemId: item.id, - startDate: manualSelection.startDate, - endDate: manualSelection.endDate, - startTime: manualSelection.startTime, - endTime: manualSelection.endTime, - totalAmount: totalCost, - deliveryMethod: "pickup", - }; - - await rentalAPI.createRental(rentalData); - navigate("/my-rentals"); - } catch (err: any) { - setError(err.response?.data?.message || "Failed to create rental"); - setSubmitting(false); - } + const handlePaymentSuccess = () => { + // This is called when Stripe checkout session is created successfully + // The rental data is already saved to localStorage via useEffect + // The actual rental creation happens in CheckoutReturn component after payment + console.log("Stripe checkout session created successfully"); }; const handleChange = ( @@ -144,41 +138,7 @@ const RentItem: React.FC = () => { > ) => { const { name, value } = e.target; - - if (name === "cardNumber") { - // Remove all non-digits - const cleaned = value.replace(/\D/g, ""); - - // Add spaces every 4 digits - const formatted = cleaned.match(/.{1,4}/g)?.join(" ") || cleaned; - - // Limit to 16 digits (19 characters with spaces) - if (cleaned.length <= 16) { - setFormData((prev) => ({ ...prev, [name]: formatted })); - } - } else if (name === "cardExpiry") { - // Remove all non-digits - const cleaned = value.replace(/\D/g, ""); - - // Add slash after 2 digits - let formatted = cleaned; - if (cleaned.length >= 3) { - formatted = cleaned.slice(0, 2) + "/" + cleaned.slice(2, 4); - } - - // Limit to 4 digits - if (cleaned.length <= 4) { - setFormData((prev) => ({ ...prev, [name]: formatted })); - } - } else if (name === "cardCVC") { - // Only allow digits and limit to 4 - const cleaned = value.replace(/\D/g, ""); - if (cleaned.length <= 4) { - setFormData((prev) => ({ ...prev, [name]: cleaned })); - } - } else { - setFormData((prev) => ({ ...prev, [name]: value })); - } + setFormData((prev) => ({ ...prev, [name]: value })); }; if (loading) { @@ -224,128 +184,29 @@ const RentItem: React.FC = () => {
-
-
-
-
Payment
+
+
+
Payment
-
- -
- - -
-
+ setError(error)} + disabled={ + !manualSelection.startDate || !manualSelection.endDate + } + /> -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- Your payment - information is secure and encrypted. You will only be - charged after the owner accepts your rental request. -
- -
- - -
-
+
- +