payment for rental from renter stripe integration
This commit is contained in:
21
backend/package-lock.json
generated
21
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,25 +215,27 @@ 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
|
||||
@@ -205,7 +243,7 @@ router.post('/:id/review-renter', authenticateToken, async (req, res) => {
|
||||
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,25 +259,27 @@ 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
|
||||
@@ -247,7 +287,7 @@ router.post('/:id/review-item', authenticateToken, async (req, res) => {
|
||||
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 });
|
||||
|
||||
206
backend/routes/stripe.js
Normal file
206
backend/routes/stripe.js
Normal file
@@ -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;
|
||||
@@ -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!" });
|
||||
|
||||
149
backend/services/stripeService.js
Normal file
149
backend/services/stripeService.js
Normal file
@@ -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;
|
||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
<MyRequests />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checkout/return"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<CheckoutReturn />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
247
frontend/src/components/CheckoutReturn.tsx
Normal file
247
frontend/src/components/CheckoutReturn.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="spinner-border mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<h3>Processing your payment...</h3>
|
||||
<p className="text-muted">
|
||||
Please wait while we confirm your payment and set up your rental.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "success") {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="alert alert-success p-4">
|
||||
<i className="bi bi-check-circle-fill display-1 text-success mb-3"></i>
|
||||
<h2>Payment Successful!</h2>
|
||||
<p className="mb-4">
|
||||
Your rental has been confirmed. You can view the details in your
|
||||
rentals page.
|
||||
</p>
|
||||
<div className="d-grid gap-2 d-md-block">
|
||||
<button
|
||||
className="btn btn-primary btn-lg me-2"
|
||||
onClick={handleGoToRentals}
|
||||
>
|
||||
View My Rentals
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-lg"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Continue Browsing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "rental_error") {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="alert alert-warning p-4">
|
||||
<i className="bi bi-exclamation-triangle-fill display-1 text-warning mb-3"></i>
|
||||
<h2>Payment Successful - Rental Setup Issue</h2>
|
||||
<p className="mb-4">
|
||||
Your payment was processed successfully, but we encountered an
|
||||
issue creating your rental record:
|
||||
<br />
|
||||
<strong>{error}</strong>
|
||||
</p>
|
||||
<div className="d-grid gap-2 d-md-block">
|
||||
<button
|
||||
className="btn btn-primary btn-lg me-2"
|
||||
onClick={handleRetryRentalCreation}
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? "Retrying..." : "Retry Rental Creation"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-lg"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Contact Support
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "failed" || status === "error") {
|
||||
return (
|
||||
<div className="container mt-5">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 text-center">
|
||||
<div className="alert alert-danger p-4">
|
||||
<i className="bi bi-exclamation-triangle-fill display-1 text-danger mb-3"></i>
|
||||
<h2>
|
||||
{status === "failed" ? "Payment Incomplete" : "Payment Error"}
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
{error || "There was an issue processing your payment."}
|
||||
</p>
|
||||
<div className="d-grid gap-2 d-md-block">
|
||||
<button
|
||||
className="btn btn-primary btn-lg me-2"
|
||||
onClick={handleRetry}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-lg"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
Go Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CheckoutReturn;
|
||||
@@ -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,55 +115,61 @@ 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}`;
|
||||
@@ -171,16 +177,9 @@ const ItemDetail: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
@@ -202,19 +201,30 @@ const ItemDetail: React.FC = () => {
|
||||
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%" }}
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
value={rentalDates.startTime}
|
||||
onChange={(e) =>
|
||||
handleDateTimeChange("startTime", e.target.value)
|
||||
handleDateTimeChange(
|
||||
"startTime",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
style={{ flex: "1 1 50%" }}
|
||||
disabled={
|
||||
!!(
|
||||
rentalDates.startDate &&
|
||||
generateTimeOptions(
|
||||
item,
|
||||
rentalDates.startDate
|
||||
).every(
|
||||
(opt) => opt.label === "Not Available"
|
||||
)
|
||||
)
|
||||
}
|
||||
style={{ flex: '1 1 50%' }}
|
||||
disabled={!!(rentalDates.startDate && generateTimeOptions(item, rentalDates.startDate).every(opt => opt.label === "Not Available"))}
|
||||
>
|
||||
{generateTimeOptions(item, rentalDates.startDate).map((option) => (
|
||||
{generateTimeOptions(
|
||||
item,
|
||||
rentalDates.startDate
|
||||
).map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
@@ -510,7 +539,7 @@ const ItemDetail: React.FC = () => {
|
||||
rentalDates.startDate ||
|
||||
new Date().toISOString().split("T")[0]
|
||||
}
|
||||
style={{ flex: '1 1 50%' }}
|
||||
style={{ flex: "1 1 50%" }}
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
@@ -518,10 +547,24 @@ const ItemDetail: React.FC = () => {
|
||||
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) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
|
||||
@@ -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 ? (
|
||||
<div className="text-center py-5">
|
||||
<h5 className="text-muted">No Active Rental Requests</h5>
|
||||
<p className="text-muted">You don't have any rental requests at the moment.</p>
|
||||
<p className="text-muted">
|
||||
You don't have any rental requests at the moment.
|
||||
</p>
|
||||
<Link to="/items" className="btn btn-primary">
|
||||
Browse Items to Rent
|
||||
</Link>
|
||||
@@ -141,7 +139,10 @@ const MyRentals: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="card h-100" style={{ cursor: rental.item ? "pointer" : "default" }}>
|
||||
<div
|
||||
className="card h-100"
|
||||
style={{ cursor: rental.item ? "pointer" : "default" }}
|
||||
>
|
||||
{rental.item?.images && rental.item.images[0] && (
|
||||
<img
|
||||
src={rental.item.images[0]}
|
||||
@@ -156,12 +157,19 @@ const MyRentals: React.FC = () => {
|
||||
</h5>
|
||||
|
||||
<div className="mb-2">
|
||||
<span className={`badge ${
|
||||
rental.status === "active" ? "bg-success" :
|
||||
rental.status === "pending" ? "bg-warning" :
|
||||
rental.status === "confirmed" ? "bg-info" : "bg-danger"
|
||||
}`}>
|
||||
{rental.status.charAt(0).toUpperCase() + rental.status.slice(1)}
|
||||
<span
|
||||
className={`badge ${
|
||||
rental.status === "active"
|
||||
? "bg-success"
|
||||
: rental.status === "pending"
|
||||
? "bg-warning"
|
||||
: rental.status === "confirmed"
|
||||
? "bg-info"
|
||||
: "bg-danger"
|
||||
}`}
|
||||
>
|
||||
{rental.status.charAt(0).toUpperCase() +
|
||||
rental.status.slice(1)}
|
||||
</span>
|
||||
{rental.paymentStatus === "paid" && (
|
||||
<span className="badge bg-success ms-2">Paid</span>
|
||||
@@ -171,9 +179,11 @@ const MyRentals: React.FC = () => {
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Rental Period:</strong>
|
||||
<br />
|
||||
<strong>Start:</strong> {formatDateTime(rental.startDate, rental.startTime)}
|
||||
<strong>Start:</strong>{" "}
|
||||
{formatDateTime(rental.startDate, rental.startTime)}
|
||||
<br />
|
||||
<strong>End:</strong> {formatDateTime(rental.endDate, rental.endTime)}
|
||||
<strong>End:</strong>{" "}
|
||||
{formatDateTime(rental.endDate, rental.endTime)}
|
||||
</p>
|
||||
|
||||
<p className="mb-1 text-dark">
|
||||
@@ -182,21 +192,28 @@ const MyRentals: React.FC = () => {
|
||||
|
||||
{rental.owner && (
|
||||
<p className="mb-1 text-dark">
|
||||
<strong>Owner:</strong> {rental.owner.firstName} {rental.owner.lastName}
|
||||
<strong>Owner:</strong> {rental.owner.firstName}{" "}
|
||||
{rental.owner.lastName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rental.renterPrivateMessage && rental.renterReviewVisible && (
|
||||
{rental.renterPrivateMessage &&
|
||||
rental.renterReviewVisible && (
|
||||
<div className="alert alert-info mt-2 mb-2 p-2 small">
|
||||
<strong><i className="bi bi-envelope-fill me-1"></i>Private Note from Owner:</strong>
|
||||
<strong>
|
||||
<i className="bi bi-envelope-fill me-1"></i>Private
|
||||
Note from Owner:
|
||||
</strong>
|
||||
<br />
|
||||
{rental.renterPrivateMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rental.status === "cancelled" && rental.rejectionReason && (
|
||||
{rental.status === "cancelled" &&
|
||||
rental.rejectionReason && (
|
||||
<div className="alert alert-warning mt-2 mb-1 p-2 small">
|
||||
<strong>Rejection reason:</strong> {rental.rejectionReason}
|
||||
<strong>Rejection reason:</strong>{" "}
|
||||
{rental.rejectionReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -209,7 +226,9 @@ const MyRentals: React.FC = () => {
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{rental.status === "active" && !rental.itemRating && !rental.itemReviewSubmittedAt && (
|
||||
{rental.status === "active" &&
|
||||
!rental.itemRating &&
|
||||
!rental.itemReviewSubmittedAt && (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleReviewClick(rental)}
|
||||
@@ -217,7 +236,8 @@ const MyRentals: React.FC = () => {
|
||||
Review
|
||||
</button>
|
||||
)}
|
||||
{rental.itemReviewSubmittedAt && !rental.itemReviewVisible && (
|
||||
{rental.itemReviewSubmittedAt &&
|
||||
!rental.itemReviewVisible && (
|
||||
<div className="text-info small">
|
||||
<i className="bi bi-clock me-1"></i>
|
||||
Review Submitted
|
||||
@@ -229,7 +249,9 @@ const MyRentals: React.FC = () => {
|
||||
Review Published ({rental.itemRating}/5)
|
||||
</div>
|
||||
)}
|
||||
{rental.status === "completed" && rental.rating && !rental.itemRating && (
|
||||
{rental.status === "completed" &&
|
||||
rental.rating &&
|
||||
!rental.itemRating && (
|
||||
<div className="text-success small">
|
||||
<i className="bi bi-check-circle-fill me-1"></i>
|
||||
Reviewed ({rental.rating}/5)
|
||||
|
||||
@@ -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<Item | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -224,120 +184,23 @@ const RentItem: React.FC = () => {
|
||||
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<h5 className="card-title">Payment</h5>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Payment Method *</label>
|
||||
<div className="form-check">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="paymentMethod"
|
||||
id="creditCard"
|
||||
value="creditCard"
|
||||
checked
|
||||
readOnly
|
||||
/>
|
||||
<label
|
||||
className="form-check-label"
|
||||
htmlFor="creditCard"
|
||||
>
|
||||
Credit/Debit Card
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-12">
|
||||
<label htmlFor="cardNumber" className="form-label">
|
||||
Card Number *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardNumber"
|
||||
name="cardNumber"
|
||||
value={formData.cardNumber}
|
||||
onChange={handleChange}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardExpiry" className="form-label">
|
||||
Expiry Date *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardExpiry"
|
||||
name="cardExpiry"
|
||||
value={formData.cardExpiry}
|
||||
onChange={handleChange}
|
||||
placeholder="MM/YY"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="cardCVC" className="form-label">
|
||||
CVC *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardCVC"
|
||||
name="cardCVC"
|
||||
value={formData.cardCVC}
|
||||
onChange={handleChange}
|
||||
placeholder="123"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="cardName" className="form-label">
|
||||
Name on Card *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="cardName"
|
||||
name="cardName"
|
||||
value={formData.cardName}
|
||||
onChange={handleChange}
|
||||
placeholder=""
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-info small">
|
||||
<i className="bi bi-info-circle"></i> Your payment
|
||||
information is secure and encrypted. You will only be
|
||||
charged after the owner accepts your rental request.
|
||||
</div>
|
||||
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
<StripePaymentForm
|
||||
total={totalCost}
|
||||
itemName={item.name}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={(error) => setError(error)}
|
||||
disabled={
|
||||
!manualSelection.startDate || !manualSelection.endDate
|
||||
}
|
||||
>
|
||||
{submitting
|
||||
? "Processing..."
|
||||
: `Confirm Rental - $${totalCost}`}
|
||||
</button>
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-secondary mt-2"
|
||||
onClick={() => navigate(`/items/${id}`)}
|
||||
>
|
||||
Cancel
|
||||
@@ -345,8 +208,6 @@ const RentItem: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="col-md-4">
|
||||
<div className="card">
|
||||
|
||||
Reference in New Issue
Block a user