emails for rental cancelation, rental declined, rental request confirmation, payout received
This commit is contained in:
@@ -55,6 +55,7 @@ const Rental = sequelize.define("Rental", {
|
||||
type: DataTypes.ENUM(
|
||||
"pending",
|
||||
"confirmed",
|
||||
"declined",
|
||||
"active",
|
||||
"completed",
|
||||
"cancelled",
|
||||
@@ -98,6 +99,9 @@ const Rental = sequelize.define("Rental", {
|
||||
cancelledAt: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
declineReason: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
stripePaymentMethodId: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
|
||||
@@ -296,12 +296,30 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
// Log error but don't fail the request
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental request email", {
|
||||
error: emailError.message,
|
||||
error: emailError,
|
||||
rentalId: rental.id,
|
||||
ownerId: rentalWithDetails.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Send rental request confirmation to renter
|
||||
try {
|
||||
await emailService.sendRentalRequestConfirmationEmail(rentalWithDetails);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental request confirmation sent to renter", {
|
||||
rentalId: rental.id,
|
||||
renterId: rentalWithDetails.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the request
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental request confirmation email", {
|
||||
error: emailError,
|
||||
rentalId: rental.id,
|
||||
renterId: rentalWithDetails.renterId,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(rentalWithDetails);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "Failed to create rental" });
|
||||
@@ -477,6 +495,105 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Decline rental request (owner only)
|
||||
router.put("/:id/decline", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { reason } = req.body;
|
||||
|
||||
// Validate that reason is provided
|
||||
if (!reason || reason.trim() === "") {
|
||||
return res.status(400).json({
|
||||
error: "A reason for declining is required",
|
||||
});
|
||||
}
|
||||
|
||||
const rental = await Rental.findByPk(req.params.id, {
|
||||
include: [
|
||||
{ model: Item, as: "item" },
|
||||
{
|
||||
model: User,
|
||||
as: "owner",
|
||||
attributes: ["id", "username", "firstName", "lastName"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "renter",
|
||||
attributes: ["id", "username", "firstName", "lastName", "email"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!rental) {
|
||||
return res.status(404).json({ error: "Rental not found" });
|
||||
}
|
||||
|
||||
// Only owner can decline
|
||||
if (rental.ownerId !== req.user.id) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the item owner can decline rental requests" });
|
||||
}
|
||||
|
||||
// Can only decline pending rentals
|
||||
if (rental.status !== "pending") {
|
||||
return res.status(400).json({
|
||||
error: "Can only decline pending rental requests",
|
||||
});
|
||||
}
|
||||
|
||||
// Update rental status to declined
|
||||
await rental.update({
|
||||
status: "declined",
|
||||
declineReason: reason,
|
||||
});
|
||||
|
||||
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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Send decline notification email to renter
|
||||
try {
|
||||
await emailService.sendRentalDeclinedEmail(updatedRental, reason);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Rental decline notification sent to renter", {
|
||||
rentalId: rental.id,
|
||||
renterId: updatedRental.renterId,
|
||||
});
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the request
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send rental decline email", {
|
||||
error: emailError,
|
||||
rentalId: rental.id,
|
||||
renterId: updatedRental.renterId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(updatedRental);
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error declining rental", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
rentalId: req.params.id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to decline rental" });
|
||||
}
|
||||
});
|
||||
|
||||
// Owner reviews renter
|
||||
router.post("/:id/review-renter", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -738,10 +855,15 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { reason } = req.body;
|
||||
|
||||
// Validate that reason is provided
|
||||
if (!reason || !reason.trim()) {
|
||||
return res.status(400).json({ error: "Cancellation reason is required" });
|
||||
}
|
||||
|
||||
const result = await RefundService.processCancellation(
|
||||
req.params.id,
|
||||
req.user.id,
|
||||
reason
|
||||
reason.trim()
|
||||
);
|
||||
|
||||
// Return the updated rental with refund information
|
||||
@@ -761,6 +883,27 @@ router.post("/:id/cancel", authenticateToken, async (req, res) => {
|
||||
],
|
||||
});
|
||||
|
||||
// Send cancellation notification emails
|
||||
try {
|
||||
await emailService.sendRentalCancellationEmails(
|
||||
updatedRental,
|
||||
result.refund
|
||||
);
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Cancellation emails sent", {
|
||||
rentalId: updatedRental.id,
|
||||
cancelledBy: updatedRental.cancelledBy,
|
||||
});
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the request
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Failed to send cancellation emails", {
|
||||
error: emailError.message,
|
||||
rentalId: updatedRental.id,
|
||||
cancelledBy: updatedRental.cancelledBy,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
rental: updatedRental,
|
||||
refund: result.refund,
|
||||
|
||||
@@ -42,6 +42,11 @@ class EmailService {
|
||||
"damageReportCS.html",
|
||||
"lostItemCS.html",
|
||||
"rentalRequest.html",
|
||||
"rentalRequestConfirmation.html",
|
||||
"rentalCancellationConfirmation.html",
|
||||
"rentalCancellationNotification.html",
|
||||
"rentalDeclined.html",
|
||||
"payoutReceived.html",
|
||||
];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
@@ -50,12 +55,14 @@ class EmailService {
|
||||
const templateContent = await fs.readFile(templatePath, "utf-8");
|
||||
const templateName = path.basename(templateFile, ".html");
|
||||
this.templates.set(templateName, templateContent);
|
||||
console.log(`✓ Loaded template: ${templateName}`);
|
||||
} catch (error) {
|
||||
console.warn(`Template ${templateFile} not found, will use fallback`);
|
||||
console.error(`✗ Failed to load template ${templateFile}:`, error.message);
|
||||
console.error(` Template path: ${path.join(templatesDir, templateFile)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Loaded ${this.templates.size} email templates`);
|
||||
console.log(`Loaded ${this.templates.size} of ${templateFiles.length} email templates`);
|
||||
} catch (error) {
|
||||
console.warn("Templates directory not found, using fallback templates");
|
||||
}
|
||||
@@ -290,6 +297,93 @@ class EmailService {
|
||||
<p>Please respond to this request within 24 hours.</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalRequestConfirmation: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterName}},</p>
|
||||
<h2>Your Rental Request Has Been Submitted!</h2>
|
||||
<p>Your request to rent <strong>{{itemName}}</strong> has been sent to the owner.</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
|
||||
<p><strong>Total Amount:</strong> \${{totalAmount}}</p>
|
||||
<p>{{paymentMessage}}</p>
|
||||
<p>You'll receive an email notification once the owner responds to your request.</p>
|
||||
<p><a href="{{viewRentalsUrl}}" class="button">View My Rentals</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalCancellationConfirmation: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Rental Cancelled Successfully</h2>
|
||||
<p>This confirms that your rental for <strong>{{itemName}}</strong> has been cancelled.</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
|
||||
{{refundSection}}
|
||||
`
|
||||
),
|
||||
|
||||
rentalCancellationNotification: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
<h2>Rental Cancellation Notice</h2>
|
||||
<p>{{cancellationMessage}}</p>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Cancelled On:</strong> {{cancelledAt}}</p>
|
||||
{{additionalInfo}}
|
||||
<p>If you have any questions or concerns, please reach out to our support team.</p>
|
||||
`
|
||||
),
|
||||
|
||||
payoutReceived: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
<h2 style="color: #28a745;">Earnings Received: \${{payoutAmount}}</h2>
|
||||
<p>Great news! Your earnings from the rental of <strong>{{itemName}}</strong> have been transferred to your account.</p>
|
||||
<h3>Rental Details</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
|
||||
<p><strong>Transfer ID:</strong> {{stripeTransferId}}</p>
|
||||
<h3>Earnings Breakdown</h3>
|
||||
<p><strong>Rental Amount:</strong> \${{totalAmount}}</p>
|
||||
<p><strong>Platform Fee (20%):</strong> -\${{platformFee}}</p>
|
||||
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
|
||||
<p>Funds are typically available in your bank account within 2-3 business days.</p>
|
||||
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
|
||||
<p>Thank you for being a valued member of the RentAll community!</p>
|
||||
`
|
||||
),
|
||||
|
||||
rentalDeclined: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterName}},</p>
|
||||
<h2>Rental Request Declined</h2>
|
||||
<p>Thank you for your interest in renting <strong>{{itemName}}</strong>. Unfortunately, the owner is unable to accept your rental request at this time.</p>
|
||||
<h3>Request Details</h3>
|
||||
<p><strong>Item:</strong> {{itemName}}</p>
|
||||
<p><strong>Start Date:</strong> {{startDate}}</p>
|
||||
<p><strong>End Date:</strong> {{endDate}}</p>
|
||||
<p><strong>Delivery Method:</strong> {{deliveryMethod}}</p>
|
||||
{{ownerMessage}}
|
||||
<div class="info-box">
|
||||
<p><strong>What happens next?</strong></p>
|
||||
<p>{{paymentMessage}}</p>
|
||||
<p>We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!</p>
|
||||
</div>
|
||||
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
|
||||
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
|
||||
`
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -456,6 +550,7 @@ class EmailService {
|
||||
? parseFloat(rental.payoutAmount).toFixed(2)
|
||||
: "0.00",
|
||||
deliveryMethod: rental.deliveryMethod || "Not specified",
|
||||
rentalNotes: rental.notes || "No additional notes provided",
|
||||
approveUrl: approveUrl,
|
||||
};
|
||||
|
||||
@@ -468,6 +563,382 @@ class EmailService {
|
||||
);
|
||||
}
|
||||
|
||||
async sendRentalRequestConfirmationEmail(rental) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const viewRentalsUrl = `${frontendUrl}/my-rentals`;
|
||||
|
||||
// Fetch renter details
|
||||
const renter = await User.findByPk(rental.renterId, {
|
||||
attributes: ["email", "firstName", "lastName"],
|
||||
});
|
||||
|
||||
if (!renter) {
|
||||
console.error(
|
||||
"Renter not found for rental request confirmation notification"
|
||||
);
|
||||
return { success: false, error: "Renter not found" };
|
||||
}
|
||||
|
||||
// Determine payment message based on rental amount
|
||||
const totalAmount = parseFloat(rental.totalAmount) || 0;
|
||||
const paymentMessage =
|
||||
totalAmount > 0
|
||||
? "The owner will review your request. You'll only be charged if they approve it."
|
||||
: "The owner will review your request and respond soon.";
|
||||
|
||||
const variables = {
|
||||
renterName: renter.firstName || "there",
|
||||
itemName: rental.item?.name || "the item",
|
||||
startDate: rental.startDateTime
|
||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified",
|
||||
endDate: rental.endDateTime
|
||||
? new Date(rental.endDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified",
|
||||
totalAmount: totalAmount.toFixed(2),
|
||||
deliveryMethod: rental.deliveryMethod || "Not specified",
|
||||
paymentMessage: paymentMessage,
|
||||
viewRentalsUrl: viewRentalsUrl,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"rentalRequestConfirmation",
|
||||
variables
|
||||
);
|
||||
|
||||
return await this.sendEmail(
|
||||
renter.email,
|
||||
`Rental Request Submitted - ${rental.item?.name || "Item"}`,
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
|
||||
async sendRentalDeclinedEmail(rental, declineReason) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const browseItemsUrl = `${frontendUrl}/`;
|
||||
|
||||
// Fetch renter details
|
||||
const renter = await User.findByPk(rental.renterId, {
|
||||
attributes: ["email", "firstName", "lastName"],
|
||||
});
|
||||
|
||||
if (!renter) {
|
||||
console.error(
|
||||
"Renter not found for rental decline notification"
|
||||
);
|
||||
return { success: false, error: "Renter not found" };
|
||||
}
|
||||
|
||||
// Determine payment message based on rental amount
|
||||
const totalAmount = parseFloat(rental.totalAmount) || 0;
|
||||
const paymentMessage =
|
||||
totalAmount > 0
|
||||
? "Since your request was declined before payment was processed, you will not be charged."
|
||||
: "No payment was required for this rental request.";
|
||||
|
||||
// Build owner message section if decline reason provided
|
||||
const ownerMessage = declineReason
|
||||
? `
|
||||
<div class="notice-box">
|
||||
<p><strong>Message from the owner:</strong></p>
|
||||
<p>${declineReason}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
const variables = {
|
||||
renterName: renter.firstName || "there",
|
||||
itemName: rental.item?.name || "the item",
|
||||
startDate: rental.startDateTime
|
||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified",
|
||||
endDate: rental.endDateTime
|
||||
? new Date(rental.endDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified",
|
||||
deliveryMethod: rental.deliveryMethod || "Not specified",
|
||||
paymentMessage: paymentMessage,
|
||||
ownerMessage: ownerMessage,
|
||||
browseItemsUrl: browseItemsUrl,
|
||||
payoutAmount: rental.payoutAmount
|
||||
? parseFloat(rental.payoutAmount).toFixed(2)
|
||||
: "0.00",
|
||||
totalAmount: totalAmount.toFixed(2),
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"rentalDeclined",
|
||||
variables
|
||||
);
|
||||
|
||||
return await this.sendEmail(
|
||||
renter.email,
|
||||
`Rental Request Declined - ${rental.item?.name || "Item"}`,
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
|
||||
async sendPayoutReceivedEmail(rental) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const earningsDashboardUrl = `${frontendUrl}/earnings`;
|
||||
|
||||
// Fetch owner details
|
||||
const owner = await User.findByPk(rental.ownerId, {
|
||||
attributes: ["email", "firstName", "lastName"],
|
||||
});
|
||||
|
||||
if (!owner) {
|
||||
console.error("Owner not found for payout notification");
|
||||
return { success: false, error: "Owner not found" };
|
||||
}
|
||||
|
||||
// Format currency values
|
||||
const totalAmount = parseFloat(rental.totalAmount) || 0;
|
||||
const platformFee = parseFloat(rental.platformFee) || 0;
|
||||
const payoutAmount = parseFloat(rental.payoutAmount) || 0;
|
||||
|
||||
const variables = {
|
||||
ownerName: owner.firstName || "there",
|
||||
itemName: rental.item?.name || "your item",
|
||||
startDate: rental.startDateTime
|
||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified",
|
||||
endDate: rental.endDateTime
|
||||
? new Date(rental.endDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified",
|
||||
totalAmount: totalAmount.toFixed(2),
|
||||
platformFee: platformFee.toFixed(2),
|
||||
payoutAmount: payoutAmount.toFixed(2),
|
||||
stripeTransferId: rental.stripeTransferId || "N/A",
|
||||
earningsDashboardUrl: earningsDashboardUrl,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("payoutReceived", variables);
|
||||
|
||||
return await this.sendEmail(
|
||||
owner.email,
|
||||
`Earnings Received - $${payoutAmount.toFixed(2)} for ${rental.item?.name || "Your Item"}`,
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
|
||||
async sendRentalCancellationEmails(rental, refundInfo) {
|
||||
const results = {
|
||||
confirmationEmailSent: false,
|
||||
notificationEmailSent: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||
const browseUrl = `${frontendUrl}/`;
|
||||
|
||||
// Fetch both owner and renter details
|
||||
const owner = await User.findByPk(rental.ownerId, {
|
||||
attributes: ["email", "firstName", "lastName"],
|
||||
});
|
||||
const renter = await User.findByPk(rental.renterId, {
|
||||
attributes: ["email", "firstName", "lastName"],
|
||||
});
|
||||
|
||||
if (!owner || !renter) {
|
||||
console.error(
|
||||
"Owner or renter not found for rental cancellation emails"
|
||||
);
|
||||
return { success: false, error: "User not found" };
|
||||
}
|
||||
|
||||
const cancelledBy = rental.cancelledBy; // 'owner' or 'renter'
|
||||
const itemName = rental.item?.name || "the item";
|
||||
const startDate = rental.startDateTime
|
||||
? new Date(rental.startDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified";
|
||||
const endDate = rental.endDateTime
|
||||
? new Date(rental.endDateTime).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified";
|
||||
const cancelledAt = rental.cancelledAt
|
||||
? new Date(rental.cancelledAt).toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "Not specified";
|
||||
|
||||
// Determine who gets confirmation and who gets notification
|
||||
let confirmationRecipient, notificationRecipient;
|
||||
let confirmationRecipientName, notificationRecipientName;
|
||||
let cancellationMessage, additionalInfo;
|
||||
|
||||
if (cancelledBy === "owner") {
|
||||
// Owner cancelled: owner gets confirmation, renter gets notification
|
||||
confirmationRecipient = owner.email;
|
||||
confirmationRecipientName = owner.firstName || "there";
|
||||
notificationRecipient = renter.email;
|
||||
notificationRecipientName = renter.firstName || "there";
|
||||
|
||||
cancellationMessage = `The owner has cancelled the rental for <strong>${itemName}</strong>. We apologize for any inconvenience this may cause.`;
|
||||
|
||||
// Only show refund info if rental had a cost
|
||||
if (rental.totalAmount > 0) {
|
||||
additionalInfo = `
|
||||
<div class="info-box">
|
||||
<p><strong>Full Refund Processed</strong></p>
|
||||
<p>You will receive a full refund of $${refundInfo.amount.toFixed(2)}. The refund will appear in your account within 5-10 business days.</p>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<a href="${browseUrl}" class="button">Browse Other Items</a>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
additionalInfo = `
|
||||
<div class="info-box">
|
||||
<p>This rental has been cancelled by the owner. We apologize for any inconvenience.</p>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<a href="${browseUrl}" class="button">Browse Other Items</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// Renter cancelled: renter gets confirmation, owner gets notification
|
||||
confirmationRecipient = renter.email;
|
||||
confirmationRecipientName = renter.firstName || "there";
|
||||
notificationRecipient = owner.email;
|
||||
notificationRecipientName = owner.firstName || "there";
|
||||
|
||||
cancellationMessage = `The renter has cancelled their rental for <strong>${itemName}</strong>.`;
|
||||
additionalInfo = `
|
||||
<div class="info-box">
|
||||
<p><strong>Your item is now available</strong></p>
|
||||
<p>Your item is now available for other renters to book for these dates.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Build refund section for confirmation email (only for paid rentals)
|
||||
let refundSection = "";
|
||||
if (rental.totalAmount > 0) {
|
||||
if (refundInfo.amount > 0) {
|
||||
const refundPercentage = (refundInfo.percentage * 100).toFixed(0);
|
||||
refundSection = `
|
||||
<h2>Refund Information</h2>
|
||||
<div class="refund-amount">$${refundInfo.amount.toFixed(2)}</div>
|
||||
<div class="info-box">
|
||||
<p><strong>Refund Amount:</strong> $${refundInfo.amount.toFixed(2)} (${refundPercentage}% of total)</p>
|
||||
<p><strong>Reason:</strong> ${refundInfo.reason}</p>
|
||||
<p><strong>Processing Time:</strong> Refunds typically appear within 5-10 business days.</p>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
refundSection = `
|
||||
<h2>Refund Information</h2>
|
||||
<div class="warning-box">
|
||||
<p><strong>No Refund Available</strong></p>
|
||||
<p>${refundInfo.reason}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
// For free rentals (totalAmount = 0), refundSection stays empty
|
||||
|
||||
// Send confirmation email to canceller
|
||||
try {
|
||||
const confirmationVariables = {
|
||||
recipientName: confirmationRecipientName,
|
||||
itemName: itemName,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
cancelledAt: cancelledAt,
|
||||
refundSection: refundSection,
|
||||
};
|
||||
|
||||
const confirmationHtml = this.renderTemplate(
|
||||
"rentalCancellationConfirmation",
|
||||
confirmationVariables
|
||||
);
|
||||
|
||||
const confirmationResult = await this.sendEmail(
|
||||
confirmationRecipient,
|
||||
`Cancellation Confirmed - ${itemName}`,
|
||||
confirmationHtml
|
||||
);
|
||||
|
||||
if (confirmationResult.success) {
|
||||
console.log(
|
||||
`Cancellation confirmation email sent to ${cancelledBy}: ${confirmationRecipient}`
|
||||
);
|
||||
results.confirmationEmailSent = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send cancellation confirmation email to ${cancelledBy}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
|
||||
// Send notification email to other party
|
||||
try {
|
||||
const notificationVariables = {
|
||||
recipientName: notificationRecipientName,
|
||||
itemName: itemName,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
cancelledAt: cancelledAt,
|
||||
cancellationMessage: cancellationMessage,
|
||||
additionalInfo: additionalInfo,
|
||||
};
|
||||
|
||||
const notificationHtml = this.renderTemplate(
|
||||
"rentalCancellationNotification",
|
||||
notificationVariables
|
||||
);
|
||||
|
||||
const notificationResult = await this.sendEmail(
|
||||
notificationRecipient,
|
||||
`Rental Cancelled - ${itemName}`,
|
||||
notificationHtml
|
||||
);
|
||||
|
||||
if (notificationResult.success) {
|
||||
console.log(
|
||||
`Cancellation notification email sent to ${cancelledBy === "owner" ? "renter" : "owner"}: ${notificationRecipient}`
|
||||
);
|
||||
results.notificationEmailSent = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send cancellation notification email:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending cancellation emails:", error);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async sendTemplateEmail(toEmail, subject, templateName, variables = {}) {
|
||||
const htmlContent = this.renderTemplate(templateName, variables);
|
||||
return await this.sendEmail(toEmail, subject, htmlContent);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { Rental, User } = require("../models");
|
||||
const { Rental, User, Item } = require("../models");
|
||||
const StripeService = require("./stripeService");
|
||||
const emailService = require("./emailService");
|
||||
const { Op } = require("sequelize");
|
||||
|
||||
class PayoutService {
|
||||
@@ -21,6 +22,10 @@ class PayoutService {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Item,
|
||||
as: "item",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -75,6 +80,20 @@ class PayoutService {
|
||||
`Payout completed for rental ${rental.id}: $${rental.payoutAmount} to ${rental.owner.stripeConnectedAccountId}`
|
||||
);
|
||||
|
||||
// Send payout notification email to owner
|
||||
try {
|
||||
await emailService.sendPayoutReceivedEmail(rental);
|
||||
console.log(
|
||||
`Payout notification email sent to owner for rental ${rental.id}`
|
||||
);
|
||||
} catch (emailError) {
|
||||
// Log error but don't fail the payout
|
||||
console.error(
|
||||
`Failed to send payout notification email for rental ${rental.id}:`,
|
||||
emailError.message
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transferId: transfer.id,
|
||||
@@ -151,6 +170,10 @@ class PayoutService {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
model: Item,
|
||||
as: "item",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
419
backend/templates/emails/payoutReceived.html
Normal file
419
backend/templates/emails/payoutReceived.html
Normal file
@@ -0,0 +1,419 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Earnings Received - RentAll</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Earnings amount display */
|
||||
.earnings-display {
|
||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.earnings-label {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.earnings-amount {
|
||||
color: #ffffff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earnings-subtitle {
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Success box */
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.success-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.success-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Breakdown table with emphasis */
|
||||
.breakdown-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.breakdown-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.breakdown-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breakdown-amount {
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.breakdown-earnings {
|
||||
background-color: #d4edda;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breakdown-earnings .breakdown-label {
|
||||
color: #155724;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.breakdown-earnings .breakdown-amount {
|
||||
color: #155724;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.earnings-amount {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Earnings Received</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{ownerName}},</p>
|
||||
|
||||
<p>Great news! Your earnings have been transferred to your account.</p>
|
||||
|
||||
<div class="earnings-display">
|
||||
<div class="earnings-label">You Earned</div>
|
||||
<div class="earnings-amount">${{payoutAmount}}</div>
|
||||
<div class="earnings-subtitle">
|
||||
From rental of {{itemName}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Rental Details</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Item Rented</th>
|
||||
<td>{{itemName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Rental Period</th>
|
||||
<td>{{startDate}} to {{endDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Transfer ID</th>
|
||||
<td>{{stripeTransferId}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>Earnings Breakdown</h2>
|
||||
<table class="breakdown-table">
|
||||
<tr>
|
||||
<td class="breakdown-label">Rental Amount (charged to renter)</td>
|
||||
<td class="breakdown-amount">${{totalAmount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="breakdown-label">Platform Fee (20%)</td>
|
||||
<td class="breakdown-amount">-${{platformFee}}</td>
|
||||
</tr>
|
||||
<tr class="breakdown-earnings">
|
||||
<td class="breakdown-label">Your Earnings</td>
|
||||
<td class="breakdown-amount">${{payoutAmount}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>When will I receive the funds?</strong></p>
|
||||
<p>
|
||||
Funds are typically available in your bank account within
|
||||
<strong>2-3 business days</strong> from the transfer date.
|
||||
</p>
|
||||
<p>
|
||||
You can track this transfer in your Stripe Dashboard using the
|
||||
Transfer ID above.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<a href="{{earningsDashboardUrl}}" class="button"
|
||||
>View Earnings Dashboard</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Thank you for being a valued member of the RentAll community! Keep
|
||||
sharing your items to earn more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll</strong></p>
|
||||
<p>
|
||||
This is a notification about your earnings. You received this message
|
||||
because a payout was successfully processed for your rental.
|
||||
</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
311
backend/templates/emails/rentalCancellationConfirmation.html
Normal file
311
backend/templates/emails/rentalCancellationConfirmation.html
Normal file
@@ -0,0 +1,311 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Cancellation Confirmed - RentAll</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Success box */
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.success-box p {
|
||||
margin: 0;
|
||||
color: #155724;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Warning box */
|
||||
.warning-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.warning-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Refund highlight */
|
||||
.refund-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #28a745;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.refund-amount {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Cancellation Confirmation</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{recipientName}},</p>
|
||||
|
||||
<div class="success-box">
|
||||
<p>Your cancellation has been confirmed</p>
|
||||
</div>
|
||||
|
||||
<h1>Rental Cancelled Successfully</h1>
|
||||
|
||||
<p>
|
||||
This confirms that your rental for <strong>{{itemName}}</strong> has
|
||||
been cancelled.
|
||||
</p>
|
||||
|
||||
<h2>Cancelled Rental Details</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<td>{{itemName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Start Date</th>
|
||||
<td>{{startDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>End Date</th>
|
||||
<td>{{endDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cancelled On</th>
|
||||
<td>{{cancelledAt}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{refundSection}}
|
||||
|
||||
<p>
|
||||
If you have any questions about this cancellation, please don't
|
||||
hesitate to contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll</strong></p>
|
||||
<p>
|
||||
This is a confirmation of your rental cancellation. You received this
|
||||
message because you cancelled a rental on RentAll.
|
||||
</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
310
backend/templates/emails/rentalCancellationNotification.html
Normal file
310
backend/templates/emails/rentalCancellationNotification.html
Normal file
@@ -0,0 +1,310 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Rental Cancelled - RentAll</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content ul {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.content li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Warning box */
|
||||
.warning-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.warning-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Rental Update</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{recipientName}},</p>
|
||||
|
||||
<h1>Rental Cancellation Notice</h1>
|
||||
|
||||
<p>{{cancellationMessage}}</p>
|
||||
|
||||
<h2>Cancelled Rental Details</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<td>{{itemName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Start Date</th>
|
||||
<td>{{startDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>End Date</th>
|
||||
<td>{{endDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Cancelled On</th>
|
||||
<td>{{cancelledAt}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{additionalInfo}}
|
||||
|
||||
<p>
|
||||
We understand this may be inconvenient. If you have any questions or
|
||||
concerns, please don't hesitate to reach out to our support team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll</strong></p>
|
||||
<p>
|
||||
This is a notification about a rental cancellation. You received this
|
||||
message because you were involved in a rental on RentAll.
|
||||
</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
317
backend/templates/emails/rentalDeclined.html
Normal file
317
backend/templates/emails/rentalDeclined.html
Normal file
@@ -0,0 +1,317 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Rental Request Declined - RentAll</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #0066cc;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Notice box */
|
||||
.notice-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.notice-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.notice-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Rental Request Update</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{renterName}},</p>
|
||||
|
||||
<p>
|
||||
Thank you for your interest in renting <strong>{{itemName}}</strong>.
|
||||
Unfortunately, the owner is unable to accept your rental request at
|
||||
this time.
|
||||
</p>
|
||||
|
||||
<h2>Request Details</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<td>{{itemName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Start Date</th>
|
||||
<td>{{startDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>End Date</th>
|
||||
<td>{{endDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Delivery Method</th>
|
||||
<td>{{deliveryMethod}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{ownerMessage}}
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>What happens next?</strong></p>
|
||||
<p>
|
||||
{{paymentMessage}}
|
||||
</p>
|
||||
<p>
|
||||
We encourage you to explore other similar items available for rent
|
||||
on RentAll. There are many great options waiting for you!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<a href="{{browseItemsUrl}}" class="button">Browse Available Items</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
If you have any questions or concerns, please don't hesitate to
|
||||
contact our support team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll</strong></p>
|
||||
<p>
|
||||
This is a notification about your rental request. You received this
|
||||
message because you submitted a rental request on RentAll.
|
||||
</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
320
backend/templates/emails/rentalRequestConfirmation.html
Normal file
320
backend/templates/emails/rentalRequestConfirmation.html
Normal file
@@ -0,0 +1,320 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Rental Request Submitted - RentAll</title>
|
||||
<style>
|
||||
/* Reset styles */
|
||||
body,
|
||||
table,
|
||||
td,
|
||||
p,
|
||||
a,
|
||||
li,
|
||||
blockquote {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: #e9ecef;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 30px 0 15px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #6c757d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0 0 10px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.info-box p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Success box */
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border-left: 4px solid #28a745;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.success-box p {
|
||||
margin: 0;
|
||||
color: #155724;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Info table */
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #e9ecef;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.content,
|
||||
.footer {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
<div class="tagline">Request Submitted</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hi {{renterName}},</p>
|
||||
|
||||
<div class="success-box">
|
||||
<p>Your rental request has been submitted!</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Your request to rent <strong>{{itemName}}</strong> has been sent to
|
||||
the owner. They'll review your request and respond soon.
|
||||
</p>
|
||||
|
||||
<h2>Request Details</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<td>{{itemName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Start Date</th>
|
||||
<td>{{startDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>End Date</th>
|
||||
<td>{{endDate}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Delivery Method</th>
|
||||
<td>{{deliveryMethod}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Total Amount</th>
|
||||
<td>${{totalAmount}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>What happens next?</strong></p>
|
||||
<p>
|
||||
{{paymentMessage}}
|
||||
</p>
|
||||
<p>
|
||||
You'll receive an email notification once the owner responds to your
|
||||
request.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center">
|
||||
<a href="{{viewRentalsUrl}}" class="button">View My Rentals</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>Need to make changes?</strong> If you need to cancel or modify
|
||||
your request, you can do so from the My Rentals page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>RentAll</strong></p>
|
||||
<p>
|
||||
This is a confirmation email for your rental request. You received
|
||||
this message because you submitted a rental request on RentAll.
|
||||
</p>
|
||||
<p>If you have any questions, please contact our support team.</p>
|
||||
<p>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -25,15 +25,43 @@ const level = () => {
|
||||
return isDevelopment ? 'debug' : process.env.LOG_LEVEL || 'info';
|
||||
};
|
||||
|
||||
// Custom format to extract stack traces from Error objects in metadata
|
||||
const extractErrorStack = winston.format((info) => {
|
||||
// Check if any metadata value is an Error object and extract its stack
|
||||
Object.keys(info).forEach(key => {
|
||||
if (info[key] instanceof Error) {
|
||||
info[key] = {
|
||||
message: info[key].message,
|
||||
stack: info[key].stack,
|
||||
name: info[key].name
|
||||
};
|
||||
}
|
||||
});
|
||||
return info;
|
||||
});
|
||||
|
||||
// Define log format
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
||||
winston.format.colorize({ all: true }),
|
||||
extractErrorStack(),
|
||||
winston.format.printf((info) => {
|
||||
if (info.stack) {
|
||||
return `${info.timestamp} ${info.level}: ${info.message}\n${info.stack}`;
|
||||
const { timestamp, level, message, stack, ...metadata } = info;
|
||||
let output = `${timestamp} ${level}: ${message}`;
|
||||
|
||||
// Check for stack trace in the info object itself
|
||||
if (stack) {
|
||||
output += `\n${stack}`;
|
||||
}
|
||||
return `${info.timestamp} ${info.level}: ${info.message}`;
|
||||
|
||||
// Check for Error objects in metadata
|
||||
Object.keys(metadata).forEach(key => {
|
||||
if (metadata[key] && metadata[key].stack) {
|
||||
output += `\n${key}: ${metadata[key].message}\n${metadata[key].stack}`;
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -41,6 +69,7 @@ const logFormat = winston.format.combine(
|
||||
const jsonFormat = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
extractErrorStack(),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
|
||||
252
frontend/src/components/DeclineRentalModal.tsx
Normal file
252
frontend/src/components/DeclineRentalModal.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { rentalAPI } from "../services/api";
|
||||
import { Rental } from "../types";
|
||||
|
||||
interface DeclineRentalModalProps {
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
rental: Rental;
|
||||
onDeclineComplete: (updatedRental: Rental) => void;
|
||||
}
|
||||
|
||||
const DeclineRentalModal: React.FC<DeclineRentalModalProps> = ({
|
||||
show,
|
||||
onHide,
|
||||
rental,
|
||||
onDeclineComplete,
|
||||
}) => {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [updatedRental, setUpdatedRental] = useState<Rental | null>(null);
|
||||
|
||||
const handleDecline = async () => {
|
||||
if (!reason.trim()) {
|
||||
setError("Please provide a reason for declining this request");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
const response = await rentalAPI.declineRental(rental.id, reason.trim());
|
||||
|
||||
// Store updated rental data for later callback
|
||||
setUpdatedRental(response.data);
|
||||
|
||||
// Show success confirmation
|
||||
setSuccess(true);
|
||||
} catch (error: any) {
|
||||
setError(
|
||||
error.response?.data?.error || "Failed to decline rental request"
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Call parent callback with updated rental data if we have it
|
||||
if (updatedRental) {
|
||||
onDeclineComplete(updatedRental);
|
||||
}
|
||||
|
||||
// Reset all states when closing
|
||||
setProcessing(false);
|
||||
setError(null);
|
||||
setReason("");
|
||||
setSuccess(false);
|
||||
setUpdatedRental(null);
|
||||
onHide();
|
||||
};
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget && !processing) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose, processing]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !processing) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose, processing]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [show, handleKeyDown]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal d-block"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="modal-dialog modal-dialog-centered">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
{success ? "Request Declined" : "Decline Rental Request"}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={handleClose}
|
||||
disabled={processing}
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="mb-4">
|
||||
<i
|
||||
className="bi bi-check-circle-fill text-success"
|
||||
style={{ fontSize: "4rem" }}
|
||||
></i>
|
||||
</div>
|
||||
<h3 className="text-success mb-3">Request Declined</h3>
|
||||
<div className="alert alert-info">
|
||||
<p className="mb-0">
|
||||
The renter has been notified that their request was
|
||||
declined.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h6>Rental Details</h6>
|
||||
<div className="bg-light p-3 rounded">
|
||||
<p className="mb-2">
|
||||
<strong>Item:</strong> {rental.item?.name}
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong>Renter:</strong> {rental.renter?.firstName}{" "}
|
||||
{rental.renter?.lastName}
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong>Start:</strong>{" "}
|
||||
{new Date(rental.startDateTime).toLocaleString()}
|
||||
</p>
|
||||
<p className="mb-0">
|
||||
<strong>End:</strong>{" "}
|
||||
{new Date(rental.endDateTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleDecline();
|
||||
}}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
Reason for Declining{" "}
|
||||
<span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="Please explain why you're unable to accept this rental request..."
|
||||
maxLength={500}
|
||||
required
|
||||
disabled={processing}
|
||||
/>
|
||||
<div className="form-text text-muted">
|
||||
{reason.length}/500 characters (Required)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-warning">
|
||||
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Note:</strong> The renter will see your reason for
|
||||
declining. No payment has been processed for this request.
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
{success ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleClose}
|
||||
disabled={processing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={handleDecline}
|
||||
disabled={processing || !reason.trim()}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
Declining...
|
||||
</>
|
||||
) : (
|
||||
"Submit"
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeclineRentalModal;
|
||||
@@ -39,6 +39,21 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check if rental status allows cancellation before making API call
|
||||
if (rental.status !== "pending" && rental.status !== "confirmed") {
|
||||
let errorMessage = "This rental cannot be cancelled";
|
||||
if (rental.status === "active") {
|
||||
errorMessage = "Cannot cancel rental - the rental period has already started";
|
||||
} else if (rental.status === "completed") {
|
||||
errorMessage = "Cannot cancel rental - the rental has already been completed";
|
||||
} else if (rental.status === "cancelled") {
|
||||
errorMessage = "This rental has already been cancelled";
|
||||
}
|
||||
setError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await rentalAPI.getRefundPreview(rental.id);
|
||||
setRefundPreview(response.data);
|
||||
} catch (error: any) {
|
||||
@@ -53,11 +68,18 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
|
||||
const handleCancel = async () => {
|
||||
if (!refundPreview) return;
|
||||
|
||||
// Validate reason is provided
|
||||
const trimmedReason = reason.trim();
|
||||
if (!trimmedReason) {
|
||||
setError("Please provide a cancellation reason");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
const response = await rentalAPI.cancelRental(rental.id, reason.trim());
|
||||
const response = await rentalAPI.cancelRental(rental.id, trimmedReason);
|
||||
|
||||
// Store refund details for confirmation screen
|
||||
setProcessedRefund({
|
||||
@@ -262,7 +284,7 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
|
||||
<form>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">
|
||||
Cancellation Reason (Optional)
|
||||
Cancellation Reason <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
@@ -271,6 +293,7 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Please provide a reason for cancellation..."
|
||||
maxLength={500}
|
||||
required
|
||||
/>
|
||||
<div className="form-text text-muted">
|
||||
{reason.length}/500 characters
|
||||
@@ -306,7 +329,7 @@ const RentalCancellationModal: React.FC<RentalCancellationModalProps> = ({
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={handleCancel}
|
||||
disabled={processing || loading}
|
||||
disabled={processing || loading || !reason.trim()}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Item, Rental } from "../types";
|
||||
import { rentalAPI, conditionCheckAPI } from "../services/api";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
import RentalCancellationModal from "../components/RentalCancellationModal";
|
||||
import DeclineRentalModal from "../components/DeclineRentalModal";
|
||||
import ConditionCheckModal from "../components/ConditionCheckModal";
|
||||
import ReturnStatusModal from "../components/ReturnStatusModal";
|
||||
|
||||
@@ -51,6 +52,8 @@ const MyListings: React.FC = () => {
|
||||
useState<Rental | null>(null);
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
const [rentalToCancel, setRentalToCancel] = useState<Rental | null>(null);
|
||||
const [showDeclineModal, setShowDeclineModal] = useState(false);
|
||||
const [rentalToDecline, setRentalToDecline] = useState<Rental | null>(null);
|
||||
const [isProcessingPayment, setIsProcessingPayment] = useState<string>("");
|
||||
const [processingSuccess, setProcessingSuccess] = useState<string>("");
|
||||
const [showConditionCheckModal, setShowConditionCheckModal] = useState(false);
|
||||
@@ -179,14 +182,20 @@ const MyListings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectRental = async (rentalId: string) => {
|
||||
try {
|
||||
await rentalAPI.updateRentalStatus(rentalId, "cancelled");
|
||||
fetchOwnerRentals();
|
||||
} catch (err) {
|
||||
console.error("Failed to reject rental request:", err);
|
||||
alert("Failed to reject rental request");
|
||||
}
|
||||
const handleDeclineClick = (rental: Rental) => {
|
||||
setRentalToDecline(rental);
|
||||
setShowDeclineModal(true);
|
||||
};
|
||||
|
||||
const handleDeclineComplete = (updatedRental: Rental) => {
|
||||
// Update the rental in the owner rentals list
|
||||
setOwnerRentals((prev) =>
|
||||
prev.map((rental) =>
|
||||
rental.id === updatedRental.id ? updatedRental : rental
|
||||
)
|
||||
);
|
||||
setShowDeclineModal(false);
|
||||
setRentalToDecline(null);
|
||||
};
|
||||
|
||||
const handleCompleteClick = (rental: Rental) => {
|
||||
@@ -252,9 +261,7 @@ const MyListings: React.FC = () => {
|
||||
|
||||
// Filter owner rentals - exclude cancelled (shown in Rental History)
|
||||
const allOwnerRentals = ownerRentals
|
||||
.filter((r) =>
|
||||
["pending", "confirmed", "active"].includes(r.status)
|
||||
)
|
||||
.filter((r) => ["pending", "confirmed", "active"].includes(r.status))
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { pending: 0, confirmed: 1, active: 2 };
|
||||
return (
|
||||
@@ -408,12 +415,12 @@ const MyListings: React.FC = () => {
|
||||
Loading...
|
||||
</span>
|
||||
</div>
|
||||
Processing Payment...
|
||||
Confirming...
|
||||
</>
|
||||
) : processingSuccess === rental.id ? (
|
||||
<>
|
||||
<i className="bi bi-check-circle me-1"></i>
|
||||
Payment Success!
|
||||
Confirmed!
|
||||
</>
|
||||
) : (
|
||||
"Accept"
|
||||
@@ -421,15 +428,9 @@ const MyListings: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRejectRental(rental.id)}
|
||||
onClick={() => handleDeclineClick(rental)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => handleCancelClick(rental)}
|
||||
>
|
||||
Cancel
|
||||
Decline
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -649,6 +650,19 @@ const MyListings: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Decline Modal */}
|
||||
{rentalToDecline && (
|
||||
<DeclineRentalModal
|
||||
show={showDeclineModal}
|
||||
onHide={() => {
|
||||
setShowDeclineModal(false);
|
||||
setRentalToDecline(null);
|
||||
}}
|
||||
rental={rentalToDecline}
|
||||
onDeclineComplete={handleDeclineComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Condition Check Modal */}
|
||||
{conditionCheckData && (
|
||||
<ConditionCheckModal
|
||||
|
||||
@@ -170,9 +170,9 @@ const MyRentals: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Filter rentals - only show active rentals (pending, confirmed, active)
|
||||
// Filter rentals - show active and declined rentals
|
||||
const renterActiveRentals = rentals.filter((r) =>
|
||||
["pending", "confirmed", "active"].includes(r.status)
|
||||
["pending", "confirmed", "declined", "active"].includes(r.status)
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -251,6 +251,8 @@ const MyRentals: React.FC = () => {
|
||||
? "bg-warning"
|
||||
: rental.status === "confirmed"
|
||||
? "bg-info"
|
||||
: rental.status === "declined"
|
||||
? "bg-secondary"
|
||||
: "bg-danger"
|
||||
}`}
|
||||
>
|
||||
@@ -258,6 +260,8 @@ const MyRentals: React.FC = () => {
|
||||
? "Awaiting Owner Approval"
|
||||
: rental.status === "confirmed"
|
||||
? "Confirmed & Paid"
|
||||
: rental.status === "declined"
|
||||
? "Declined by Owner"
|
||||
: rental.status.charAt(0).toUpperCase() +
|
||||
rental.status.slice(1)}
|
||||
</span>
|
||||
@@ -295,14 +299,15 @@ const MyRentals: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rental.status === "cancelled" && (
|
||||
<>
|
||||
{rental.rejectionReason && (
|
||||
{rental.status === "declined" && rental.declineReason && (
|
||||
<div className="alert alert-warning mt-2 mb-1 p-2 small">
|
||||
<strong>Rejection reason:</strong>{" "}
|
||||
{rental.rejectionReason}
|
||||
<strong>Decline reason:</strong>{" "}
|
||||
{rental.declineReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rental.status === "cancelled" && (
|
||||
<>
|
||||
{rental.refundAmount !== undefined && (
|
||||
<div className="alert alert-info mt-2 mb-1 p-2 small">
|
||||
<strong>
|
||||
|
||||
@@ -940,11 +940,11 @@ const Profile: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rental.status === "cancelled" &&
|
||||
rental.rejectionReason && (
|
||||
{rental.status === "declined" &&
|
||||
rental.declineReason && (
|
||||
<div className="alert alert-warning mt-2 mb-1 p-2 small">
|
||||
<strong>Rejection reason:</strong>{" "}
|
||||
{rental.rejectionReason}
|
||||
<strong>Decline reason:</strong>{" "}
|
||||
{rental.declineReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -219,6 +219,8 @@ export const rentalAPI = {
|
||||
}),
|
||||
cancelRental: (id: string, reason?: string) =>
|
||||
api.post(`/rentals/${id}/cancel`, { reason }),
|
||||
declineRental: (id: string, reason?: string) =>
|
||||
api.put(`/rentals/${id}/decline`, { reason }),
|
||||
// Return status marking
|
||||
markReturn: (
|
||||
id: string,
|
||||
|
||||
@@ -110,10 +110,12 @@ export interface Rental {
|
||||
status:
|
||||
| "pending"
|
||||
| "confirmed"
|
||||
| "declined"
|
||||
| "active"
|
||||
| "completed"
|
||||
| "cancelled"
|
||||
| "returned_late"
|
||||
| "returned_late_and_damaged"
|
||||
| "damaged"
|
||||
| "lost";
|
||||
paymentStatus: "pending" | "paid" | "refunded";
|
||||
@@ -135,7 +137,7 @@ export interface Rental {
|
||||
notes?: string;
|
||||
rating?: number;
|
||||
review?: string;
|
||||
rejectionReason?: string;
|
||||
declineReason?: string;
|
||||
// New review fields
|
||||
itemRating?: number;
|
||||
itemReview?: string;
|
||||
|
||||
Reference in New Issue
Block a user