payment confirmation for renter after rental request approval, first listing celebration email, removed burstprotection for google places autocomplete, renamed email templates

This commit is contained in:
jackiettran
2025-10-28 22:23:41 -04:00
parent 502d84a741
commit d1cb857aa7
25 changed files with 2171 additions and 53 deletions

View File

@@ -108,6 +108,15 @@ const Rental = sequelize.define("Rental", {
stripePaymentIntentId: { stripePaymentIntentId: {
type: DataTypes.STRING, type: DataTypes.STRING,
}, },
paymentMethodBrand: {
type: DataTypes.STRING,
},
paymentMethodLast4: {
type: DataTypes.STRING,
},
chargedAt: {
type: DataTypes.DATE,
},
deliveryMethod: { deliveryMethod: {
type: DataTypes.ENUM("pickup", "delivery"), type: DataTypes.ENUM("pickup", "delivery"),
defaultValue: "pickup", defaultValue: "pickup",

View File

@@ -225,16 +225,37 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
{ {
model: User, model: User,
as: "owner", as: "owner",
attributes: ["id", "username", "firstName", "lastName"], attributes: ["id", "username", "firstName", "lastName", "email", "stripeConnectedAccountId"],
}, },
], ],
}); });
// Check if this is the owner's first listing
const ownerItemCount = await Item.count({
where: { ownerId: req.user.id }
});
// If first listing, send celebration email
if (ownerItemCount === 1) {
try {
const emailService = require("../services/emailService");
await emailService.sendFirstListingCelebrationEmail(
itemWithOwner.owner,
itemWithOwner
);
console.log(`First listing celebration email sent to owner ${req.user.id}`);
} catch (emailError) {
// Log but don't fail the item creation
console.error('Failed to send first listing celebration email:', emailError.message);
}
}
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Item created", { reqLogger.info("Item created", {
itemId: item.id, itemId: item.id,
ownerId: req.user.id, ownerId: req.user.id,
itemName: req.body.name itemName: req.body.name,
isFirstListing: ownerItemCount === 1
}); });
res.status(201).json(itemWithOwner); res.status(201).json(itemWithOwner);

View File

@@ -69,7 +69,6 @@ const handleServiceError = (error, res, req) => {
router.post( router.post(
"/places/autocomplete", "/places/autocomplete",
authenticateToken, authenticateToken,
rateLimiter.burstProtection,
rateLimiter.placesAutocomplete, rateLimiter.placesAutocomplete,
validateInput, validateInput,
async (req, res) => { async (req, res) => {
@@ -150,7 +149,6 @@ router.post(
router.post( router.post(
"/geocode", "/geocode",
authenticateToken, authenticateToken,
rateLimiter.burstProtection,
rateLimiter.geocoding, rateLimiter.geocoding,
validateInput, validateInput,
async (req, res) => { async (req, res) => {

View File

@@ -404,6 +404,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
status: "confirmed", status: "confirmed",
paymentStatus: "paid", paymentStatus: "paid",
stripePaymentIntentId: paymentResult.paymentIntentId, stripePaymentIntentId: paymentResult.paymentIntentId,
paymentMethodBrand: paymentResult.paymentMethod?.brand || null,
paymentMethodLast4: paymentResult.paymentMethod?.last4 || null,
chargedAt: paymentResult.chargedAt || new Date(),
}); });
const updatedRental = await Rental.findByPk(rental.id, { const updatedRental = await Rental.findByPk(rental.id, {
@@ -423,7 +426,58 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}); });
// Send confirmation emails // Send confirmation emails
await emailService.sendRentalConfirmationEmails(updatedRental); // Send approval confirmation to owner with Stripe reminder
try {
await emailService.sendRentalApprovalConfirmationEmail(updatedRental);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send rental approval confirmation email to owner", {
error: emailError.message,
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
});
}
// Send rental confirmation to renter with payment receipt
try {
const renter = await User.findByPk(updatedRental.renterId, {
attributes: ["email", "firstName"],
});
if (renter) {
const renterNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your rental of "${updatedRental.item.name}" has been confirmed.`,
rentalId: updatedRental.id,
userId: updatedRental.renterId,
metadata: { rentalStart: updatedRental.startDateTime },
};
await emailService.sendRentalConfirmation(
renter.email,
renterNotification,
updatedRental,
renter.firstName,
true // isRenter = true to show payment receipt
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter", {
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
});
}
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send rental confirmation email to renter", {
error: emailError.message,
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
});
}
res.json(updatedRental); res.json(updatedRental);
return; return;
@@ -464,7 +518,58 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
}); });
// Send confirmation emails // Send confirmation emails
await emailService.sendRentalConfirmationEmails(updatedRental); // Send approval confirmation to owner (for free rentals, no Stripe reminder shown)
try {
await emailService.sendRentalApprovalConfirmationEmail(updatedRental);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental approval confirmation sent to owner", {
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send rental approval confirmation email to owner", {
error: emailError.message,
rentalId: updatedRental.id,
ownerId: updatedRental.ownerId,
});
}
// Send rental confirmation to renter
try {
const renter = await User.findByPk(updatedRental.renterId, {
attributes: ["email", "firstName"],
});
if (renter) {
const renterNotification = {
type: "rental_confirmed",
title: "Rental Confirmed",
message: `Your rental of "${updatedRental.item.name}" has been confirmed.`,
rentalId: updatedRental.id,
userId: updatedRental.renterId,
metadata: { rentalStart: updatedRental.startDateTime },
};
await emailService.sendRentalConfirmation(
renter.email,
renterNotification,
updatedRental,
renter.firstName,
true // isRenter = true (for free rentals, shows "no payment required")
);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental confirmation sent to renter", {
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
});
}
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send rental confirmation email to renter", {
error: emailError.message,
rentalId: updatedRental.id,
renterId: updatedRental.renterId,
});
}
res.json(updatedRental); res.json(updatedRental);
return; return;
@@ -958,6 +1063,40 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
actualReturnDateTime: actualReturnDateTime || rental.endDateTime, actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
notes: notes || null, notes: notes || null,
}); });
// Fetch full rental details with associations for email
const rentalWithDetails = await Rental.findByPk(rentalId, {
include: [
{ model: Item, as: "item" },
{
model: User,
as: "owner",
attributes: ["id", "username", "firstName", "lastName"],
},
{
model: User,
as: "renter",
attributes: ["id", "username", "firstName", "lastName"],
},
],
});
// Send completion emails to both renter and owner
try {
await emailService.sendRentalCompletionEmails(rentalWithDetails);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Rental completion emails sent", {
rentalId,
ownerId: rental.ownerId,
renterId: rental.renterId,
});
} catch (emailError) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send rental completion emails", {
error: emailError.message,
rentalId,
});
}
break; break;
case "damaged": case "damaged":

View File

@@ -33,20 +33,24 @@ class EmailService {
try { try {
const templateFiles = [ const templateFiles = [
"conditionCheckReminder.html", "conditionCheckReminderToUser.html",
"rentalConfirmation.html", "rentalConfirmationToUser.html",
"emailVerification.html", "emailVerificationToUser.html",
"passwordReset.html", "passwordResetToUser.html",
"passwordChanged.html", "passwordChangedToUser.html",
"lateReturnCS.html", "lateReturnToCS.html",
"damageReportCS.html", "damageReportToCS.html",
"lostItemCS.html", "lostItemToCS.html",
"rentalRequest.html", "rentalRequestToOwner.html",
"rentalRequestConfirmation.html", "rentalRequestConfirmationToRenter.html",
"rentalCancellationConfirmation.html", "rentalCancellationConfirmationToUser.html",
"rentalCancellationNotification.html", "rentalCancellationNotificationToUser.html",
"rentalDeclined.html", "rentalDeclinedToRenter.html",
"payoutReceived.html", "rentalApprovalConfirmationToOwner.html",
"rentalCompletionThankYouToRenter.html",
"rentalCompletionCongratsToOwner.html",
"payoutReceivedToOwner.html",
"firstListingCelebrationToOwner.html",
]; ];
for (const templateFile of templateFiles) { for (const templateFile of templateFiles) {
@@ -222,7 +226,7 @@ class EmailService {
`; `;
const templates = { const templates = {
conditionCheckReminder: baseTemplate.replace( conditionCheckReminderToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<h2>{{title}}</h2> <h2>{{title}}</h2>
@@ -233,7 +237,7 @@ class EmailService {
` `
), ),
rentalConfirmation: baseTemplate.replace( rentalConfirmationToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
@@ -245,7 +249,7 @@ class EmailService {
` `
), ),
emailVerification: baseTemplate.replace( emailVerificationToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
@@ -257,7 +261,7 @@ class EmailService {
` `
), ),
passwordReset: baseTemplate.replace( passwordResetToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
@@ -270,7 +274,7 @@ class EmailService {
` `
), ),
passwordChanged: baseTemplate.replace( passwordChangedToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
@@ -282,7 +286,7 @@ class EmailService {
` `
), ),
rentalRequest: baseTemplate.replace( rentalRequestToOwner: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{ownerName}},</p> <p>Hi {{ownerName}},</p>
@@ -298,7 +302,7 @@ class EmailService {
` `
), ),
rentalRequestConfirmation: baseTemplate.replace( rentalRequestConfirmationToRenter: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{renterName}},</p> <p>Hi {{renterName}},</p>
@@ -314,7 +318,7 @@ class EmailService {
` `
), ),
rentalCancellationConfirmation: baseTemplate.replace( rentalCancellationConfirmationToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
@@ -328,7 +332,7 @@ class EmailService {
` `
), ),
rentalCancellationNotification: baseTemplate.replace( rentalCancellationNotificationToUser: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{recipientName}},</p> <p>Hi {{recipientName}},</p>
@@ -343,7 +347,7 @@ class EmailService {
` `
), ),
payoutReceived: baseTemplate.replace( payoutReceivedToOwner: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{ownerName}},</p> <p>Hi {{ownerName}},</p>
@@ -363,7 +367,7 @@ class EmailService {
` `
), ),
rentalDeclined: baseTemplate.replace( rentalDeclinedToRenter: baseTemplate.replace(
"{{content}}", "{{content}}",
` `
<p>Hi {{renterName}},</p> <p>Hi {{renterName}},</p>
@@ -384,6 +388,60 @@ class EmailService {
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p> <p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
` `
), ),
rentalApprovalConfirmationToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>You've Approved the Rental Request!</h2>
<p>You've successfully approved the rental request for <strong>{{itemName}}</strong>.</p>
<h3>Rental Details</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Renter:</strong> {{renterName}}</p>
<p><strong>Start Date:</strong> {{startDate}}</p>
<p><strong>End Date:</strong> {{endDate}}</p>
<p><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
{{stripeSection}}
<h3>What's Next?</h3>
<ul>
<li>Coordinate with the renter on pickup details</li>
<li>Take photos of the item's condition before handoff</li>
<li>Provide any care instructions or usage tips</li>
</ul>
<p><a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a></p>
`
),
rentalCompletionThankYouToRenter: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{renterName}},</p>
<h2>Thank You for Returning On Time!</h2>
<p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the RentAll community!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p><strong>Returned On:</strong> {{returnedDate}}</p>
{{reviewSection}}
<p><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
`
),
rentalCompletionCongratsToOwner: baseTemplate.replace(
"{{content}}",
`
<p>Hi {{ownerName}},</p>
<h2>Congratulations on Completing a Rental!</h2>
<p><strong>{{itemName}}</strong> has been successfully returned on time. Great job!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Renter:</strong> {{renterName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
{{earningsSection}}
{{stripeSection}}
<p><a href="{{myListingsUrl}}" class="button">View My Listings</a></p>
`
),
}; };
return ( return (
@@ -409,7 +467,7 @@ class EmailService {
}; };
const htmlContent = this.renderTemplate( const htmlContent = this.renderTemplate(
"conditionCheckReminder", "conditionCheckReminderToUser",
variables variables
); );
@@ -424,7 +482,8 @@ class EmailService {
userEmail, userEmail,
notification, notification,
rental, rental,
recipientName = null recipientName = null,
isRenter = false
) { ) {
const itemName = rental?.item?.name || "Unknown Item"; const itemName = rental?.item?.name || "Unknown Item";
@@ -439,9 +498,77 @@ class EmailService {
endDate: rental?.endDateTime endDate: rental?.endDateTime
? new Date(rental.endDateTime).toLocaleDateString() ? new Date(rental.endDateTime).toLocaleDateString()
: "Not specified", : "Not specified",
isRenter: isRenter,
}; };
const htmlContent = this.renderTemplate("rentalConfirmation", variables); // Add payment information if this is for the renter and rental has payment info
let paymentSection = "";
if (isRenter) {
const totalAmount = parseFloat(rental.totalAmount) || 0;
const isPaidRental = totalAmount > 0 && rental.paymentStatus === 'paid';
if (isPaidRental) {
// Format payment method display
let paymentMethodDisplay = "Payment method on file";
if (rental.paymentMethodBrand && rental.paymentMethodLast4) {
const brandCapitalized = rental.paymentMethodBrand.charAt(0).toUpperCase() +
rental.paymentMethodBrand.slice(1);
paymentMethodDisplay = `${brandCapitalized} ending in ${rental.paymentMethodLast4}`;
}
const chargedAtFormatted = rental.chargedAt
? new Date(rental.chargedAt).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short"
})
: new Date().toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short"
});
// Build payment receipt section HTML
paymentSection = `
<h2>Payment Receipt</h2>
<div class="success-box">
<div class="icon">💳</div>
<p><strong>Payment Successful</strong></p>
<p>Your payment has been processed. This email serves as your receipt.</p>
</div>
<table class="info-table">
<tr>
<th>Amount Charged</th>
<td><strong>$${totalAmount.toFixed(2)}</strong></td>
</tr>
<tr>
<th>Payment Method</th>
<td>${paymentMethodDisplay}</td>
</tr>
<tr>
<th>Transaction ID</th>
<td style="font-family: monospace; font-size: 12px;">${rental.stripePaymentIntentId || "N/A"}</td>
</tr>
<tr>
<th>Transaction Date</th>
<td>${chargedAtFormatted}</td>
</tr>
</table>
<p style="font-size: 13px; color: #6c757d; margin-top: 10px;">
<strong>Note:</strong> Keep this email for your records. You can use the transaction ID above if you need to contact support about this payment.
</p>
`;
} else if (totalAmount === 0) {
// Free rental message
paymentSection = `
<div class="success-box">
<p><strong>No Payment Required:</strong> This is a free rental.</p>
</div>
`;
}
}
variables.paymentSection = paymentSection;
const htmlContent = this.renderTemplate("rentalConfirmationToUser", variables);
// Use clear, transactional subject line with item name // Use clear, transactional subject line with item name
const subject = `Rental Confirmation - ${itemName}`; const subject = `Rental Confirmation - ${itemName}`;
@@ -458,7 +585,7 @@ class EmailService {
verificationUrl: verificationUrl, verificationUrl: verificationUrl,
}; };
const htmlContent = this.renderTemplate("emailVerification", variables); const htmlContent = this.renderTemplate("emailVerificationToUser", variables);
return await this.sendEmail( return await this.sendEmail(
user.email, user.email,
@@ -476,7 +603,7 @@ class EmailService {
resetUrl: resetUrl, resetUrl: resetUrl,
}; };
const htmlContent = this.renderTemplate("passwordReset", variables); const htmlContent = this.renderTemplate("passwordResetToUser", variables);
return await this.sendEmail( return await this.sendEmail(
user.email, user.email,
@@ -497,7 +624,7 @@ class EmailService {
timestamp: timestamp, timestamp: timestamp,
}; };
const htmlContent = this.renderTemplate("passwordChanged", variables); const htmlContent = this.renderTemplate("passwordChangedToUser", variables);
return await this.sendEmail( return await this.sendEmail(
user.email, user.email,
@@ -554,7 +681,7 @@ class EmailService {
approveUrl: approveUrl, approveUrl: approveUrl,
}; };
const htmlContent = this.renderTemplate("rentalRequest", variables); const htmlContent = this.renderTemplate("rentalRequestToOwner", variables);
return await this.sendEmail( return await this.sendEmail(
owner.email, owner.email,
@@ -608,7 +735,7 @@ class EmailService {
}; };
const htmlContent = this.renderTemplate( const htmlContent = this.renderTemplate(
"rentalRequestConfirmation", "rentalRequestConfirmationToRenter",
variables variables
); );
@@ -678,7 +805,7 @@ class EmailService {
}; };
const htmlContent = this.renderTemplate( const htmlContent = this.renderTemplate(
"rentalDeclined", "rentalDeclinedToRenter",
variables variables
); );
@@ -730,7 +857,7 @@ class EmailService {
earningsDashboardUrl: earningsDashboardUrl, earningsDashboardUrl: earningsDashboardUrl,
}; };
const htmlContent = this.renderTemplate("payoutReceived", variables); const htmlContent = this.renderTemplate("payoutReceivedToOwner", variables);
return await this.sendEmail( return await this.sendEmail(
owner.email, owner.email,
@@ -874,7 +1001,7 @@ class EmailService {
}; };
const confirmationHtml = this.renderTemplate( const confirmationHtml = this.renderTemplate(
"rentalCancellationConfirmation", "rentalCancellationConfirmationToUser",
confirmationVariables confirmationVariables
); );
@@ -910,7 +1037,7 @@ class EmailService {
}; };
const notificationHtml = this.renderTemplate( const notificationHtml = this.renderTemplate(
"rentalCancellationNotification", "rentalCancellationNotificationToUser",
notificationVariables notificationVariables
); );
@@ -965,7 +1092,7 @@ class EmailService {
await this.sendTemplateEmail( await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL, process.env.CUSTOMER_SUPPORT_EMAIL,
"Late Return Detected - Action Required", "Late Return Detected - Action Required",
"lateReturnCS", "lateReturnToCS",
{ {
rentalId: rental.id, rentalId: rental.id,
itemName: rental.item.name, itemName: rental.item.name,
@@ -1027,7 +1154,7 @@ class EmailService {
await this.sendTemplateEmail( await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL, process.env.CUSTOMER_SUPPORT_EMAIL,
"Damage Report Filed - Action Required", "Damage Report Filed - Action Required",
"damageReportCS", "damageReportToCS",
{ {
rentalId: rental.id, rentalId: rental.id,
itemName: rental.item.name, itemName: rental.item.name,
@@ -1086,7 +1213,7 @@ class EmailService {
await this.sendTemplateEmail( await this.sendTemplateEmail(
process.env.CUSTOMER_SUPPORT_EMAIL, process.env.CUSTOMER_SUPPORT_EMAIL,
"Lost Item Claim Filed - Action Required", "Lost Item Claim Filed - Action Required",
"lostItemCS", "lostItemToCS",
{ {
rentalId: rental.id, rentalId: rental.id,
itemName: rental.item.name, itemName: rental.item.name,
@@ -1153,7 +1280,8 @@ class EmailService {
owner.email, owner.email,
ownerNotification, ownerNotification,
rental, rental,
owner.firstName owner.firstName,
false // isRenter = false for owner
); );
if (ownerResult.success) { if (ownerResult.success) {
console.log( console.log(
@@ -1181,7 +1309,8 @@ class EmailService {
renter.email, renter.email,
renterNotification, renterNotification,
rental, rental,
renter.firstName renter.firstName,
true // isRenter = true for renter (enables payment receipt)
); );
if (renterResult.success) { if (renterResult.success) {
console.log( console.log(
@@ -1210,6 +1339,391 @@ class EmailService {
return results; return results;
} }
async sendFirstListingCelebrationEmail(owner, item) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const variables = {
ownerName: owner.firstName || "there",
itemName: item.name,
itemId: item.id,
viewItemUrl: `${frontendUrl}/items/${item.id}`,
};
const htmlContent = this.renderTemplate(
"firstListingCelebrationToOwner",
variables
);
const subject = `🎉 Congratulations! Your first item is live on RentAll`;
return await this.sendEmail(owner.email, subject, htmlContent);
}
async sendRentalApprovalConfirmationEmail(rental) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
// Fetch owner details
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email", "firstName", "lastName", "stripeConnectedAccountId"],
});
// Fetch renter details
const renter = await User.findByPk(rental.renterId, {
attributes: ["firstName", "lastName"],
});
if (!owner || !renter) {
console.error(
"Owner or renter not found for rental approval confirmation email"
);
return { success: false, error: "User not found" };
}
// Determine if Stripe setup is needed
const hasStripeAccount = !!owner.stripeConnectedAccountId;
const totalAmount = parseFloat(rental.totalAmount) || 0;
const payoutAmount = parseFloat(rental.payoutAmount) || 0;
const platformFee = parseFloat(rental.platformFee) || 0;
// Build payment message
const isPaidRental = totalAmount > 0;
let paymentMessage = "";
if (isPaidRental) {
paymentMessage = "their payment has been processed successfully.";
} else {
paymentMessage = "this is a free rental (no payment required).";
}
// Build earnings section (only for paid rentals)
let earningsSection = "";
if (isPaidRental) {
earningsSection = `
<h2>Your Earnings</h2>
<table class="info-table">
<tr>
<th>Total Rental Amount</th>
<td>\$${totalAmount.toFixed(2)}</td>
</tr>
<tr>
<th>Platform Fee (20%)</th>
<td>-\$${platformFee.toFixed(2)}</td>
</tr>
<tr>
<th>Your Payout</th>
<td class="highlight">\$${payoutAmount.toFixed(2)}</td>
</tr>
</table>
`;
}
// Build conditional Stripe section based on Stripe status
let stripeSection = "";
if (!hasStripeAccount && isPaidRental) {
// Only show Stripe setup reminder for paid rentals
stripeSection = `
<div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\$${payoutAmount.toFixed(2)}</strong> when this rental completes, you need to set up your earnings account.</p>
</div>
<h2>Set Up Earnings to Get Paid</h2>
<div class="info-box">
<p><strong>Why set up now?</strong></p>
<ul>
<li><strong>Automatic payouts</strong> when rentals complete</li>
<li><strong>Secure transfers</strong> directly to your bank account</li>
<li><strong>Track all earnings</strong> in one dashboard</li>
<li><strong>Fast deposits</strong> (typically 2-3 business days)</li>
</ul>
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
</div>
<p style="text-align: center;">
<a href="${frontendUrl}/earnings" class="button">Set Up Earnings Account Now</a>
</p>
<p style="text-align: center; font-size: 14px; color: #856404;">
<strong>Important:</strong> Without earnings setup, you won't receive payouts automatically when rentals complete.
</p>
`;
} else if (hasStripeAccount && isPaidRental) {
stripeSection = `
<div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive \$${payoutAmount.toFixed(2)} when this rental completes.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div>
`;
}
// Format delivery method for display
const deliveryMethodDisplay = rental.deliveryMethod === "delivery" ? "Delivery" : "Pickup";
const variables = {
ownerName: owner.firstName || "there",
itemName: rental.item?.name || "your item",
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "The renter",
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: deliveryMethodDisplay,
paymentMessage: paymentMessage,
earningsSection: earningsSection,
stripeSection: stripeSection,
rentalDetailsUrl: `${frontendUrl}/my-listings?rentalId=${rental.id}`,
};
const htmlContent = this.renderTemplate(
"rentalApprovalConfirmationToOwner",
variables
);
const subject = `Rental Approved - ${rental.item?.name || "Your Item"}`;
return await this.sendEmail(owner.email, subject, htmlContent);
}
async sendRentalCompletionEmails(rental) {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const results = {
renterEmailSent: false,
ownerEmailSent: false,
};
try {
// Fetch owner details with Stripe info
const owner = await User.findByPk(rental.ownerId, {
attributes: ["email", "firstName", "lastName", "stripeConnectedAccountId"],
});
// Fetch renter details
const renter = await User.findByPk(rental.renterId, {
attributes: ["email", "firstName", "lastName"],
});
if (!owner || !renter) {
console.error(
"Owner or renter not found for rental completion emails"
);
return { success: false, error: "User not found" };
}
// Format dates
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 returnedDate = rental.actualReturnDateTime
? new Date(rental.actualReturnDateTime).toLocaleString("en-US", {
dateStyle: "medium",
timeStyle: "short",
})
: endDate;
// Check if renter has already submitted a review
const hasReviewed = !!rental.itemReviewSubmittedAt;
// Build review section for renter email
let reviewSection = "";
if (!hasReviewed) {
reviewSection = `
<h2>Share Your Experience</h2>
<div class="info-box">
<p><strong>Help the community by leaving a review!</strong></p>
<p>Your feedback helps other renters make informed decisions and supports quality listings on RentAll.</p>
<ul>
<li>How was the item's condition?</li>
<li>Was the owner responsive and helpful?</li>
<li>Would you rent this item again?</li>
</ul>
</div>
<p style="text-align: center;">
<a href="${frontendUrl}/my-rentals?rentalId=${rental.id}&action=review" class="button">Leave a Review</a>
</p>
`;
} else {
reviewSection = `
<div class="success-box">
<p><strong>✓ Thank You for Your Review!</strong></p>
<p>Your feedback has been submitted and helps strengthen the RentAll community.</p>
</div>
`;
}
// Send email to renter
try {
const renterVariables = {
renterName: renter.firstName || "there",
itemName: rental.item?.name || "the item",
ownerName: owner.firstName || "the owner",
startDate: startDate,
endDate: endDate,
returnedDate: returnedDate,
reviewSection: reviewSection,
browseItemsUrl: `${frontendUrl}/`,
};
const renterHtmlContent = this.renderTemplate(
"rentalCompletionThankYouToRenter",
renterVariables
);
const renterResult = await this.sendEmail(
renter.email,
`Thank You for Returning "${rental.item?.name || "Item"}" On Time!`,
renterHtmlContent
);
if (renterResult.success) {
console.log(
`Rental completion thank you email sent to renter: ${renter.email}`
);
results.renterEmailSent = true;
} else {
console.error(
`Failed to send rental completion email to renter (${renter.email}):`,
renterResult.error
);
}
} catch (emailError) {
console.error(
`Failed to send rental completion email to renter (${renter.email}):`,
emailError.message
);
}
// Prepare owner email
const hasStripeAccount = !!owner.stripeConnectedAccountId;
const totalAmount = parseFloat(rental.totalAmount) || 0;
const payoutAmount = parseFloat(rental.payoutAmount) || 0;
const platformFee = parseFloat(rental.platformFee) || 0;
const isPaidRental = totalAmount > 0;
// Build earnings section for owner (only for paid rentals)
let earningsSection = "";
if (isPaidRental) {
earningsSection = `
<h2>Your Earnings</h2>
<table class="info-table">
<tr>
<th>Total Rental Amount</th>
<td>\$${totalAmount.toFixed(2)}</td>
</tr>
<tr>
<th>Platform Fee (20%)</th>
<td>-\$${platformFee.toFixed(2)}</td>
</tr>
<tr>
<th>Your Payout</th>
<td class="highlight">\$${payoutAmount.toFixed(2)}</td>
</tr>
</table>
<p style="font-size: 14px; color: #6c757d;">
Your earnings will be automatically transferred to your account when the rental period ends and any dispute windows close.
</p>
`;
}
// Build Stripe section for owner
let stripeSection = "";
if (!hasStripeAccount && isPaidRental) {
// Show Stripe setup reminder for paid rentals
stripeSection = `
<div class="warning-box">
<p><strong>⚠️ Action Required: Set Up Your Earnings Account</strong></p>
<p>To receive your payout of <strong>\$${payoutAmount.toFixed(2)}</strong>, you need to set up your earnings account.</p>
</div>
<h2>Set Up Earnings to Get Paid</h2>
<div class="info-box">
<p><strong>Why set up now?</strong></p>
<ul>
<li><strong>Automatic payouts</strong> when the rental period ends</li>
<li><strong>Secure transfers</strong> directly to your bank account</li>
<li><strong>Track all earnings</strong> in one dashboard</li>
<li><strong>Fast deposits</strong> (typically 2-3 business days)</li>
</ul>
<p>Setup only takes about 5 minutes and you only need to do it once.</p>
</div>
<p style="text-align: center;">
<a href="${frontendUrl}/earnings" class="button">Set Up Earnings Account Now</a>
</p>
<p style="text-align: center; font-size: 14px; color: #856404;">
<strong>Important:</strong> Without earnings setup, you won't receive payouts automatically.
</p>
`;
} else if (hasStripeAccount && isPaidRental) {
stripeSection = `
<div class="success-box">
<p><strong>✓ Earnings Account Active</strong></p>
<p>Your earnings account is set up. You'll automatically receive \$${payoutAmount.toFixed(2)} when the rental period ends.</p>
<p><a href="${frontendUrl}/earnings" style="color: #155724; text-decoration: underline;">View your earnings dashboard →</a></p>
</div>
`;
}
// Send email to owner
try {
const ownerVariables = {
ownerName: owner.firstName || "there",
itemName: rental.item?.name || "your item",
renterName: `${renter.firstName} ${renter.lastName}`.trim() || "The renter",
startDate: startDate,
endDate: endDate,
returnedDate: returnedDate,
earningsSection: earningsSection,
stripeSection: stripeSection,
myListingsUrl: `${frontendUrl}/my-listings`,
};
const ownerHtmlContent = this.renderTemplate(
"rentalCompletionCongratsToOwner",
ownerVariables
);
const ownerResult = await this.sendEmail(
owner.email,
`Rental Complete - ${rental.item?.name || "Your Item"}`,
ownerHtmlContent
);
if (ownerResult.success) {
console.log(
`Rental completion congratulations email sent to owner: ${owner.email}`
);
results.ownerEmailSent = true;
} else {
console.error(
`Failed to send rental completion email to owner (${owner.email}):`,
ownerResult.error
);
}
} catch (emailError) {
console.error(
`Failed to send rental completion email to owner (${owner.email}):`,
emailError.message
);
}
} catch (error) {
console.error("Error sending rental completion emails:", error);
}
return results;
}
} }
module.exports = new EmailService(); module.exports = new EmailService();

View File

@@ -123,14 +123,48 @@ class StripeService {
payment_method: paymentMethodId, payment_method: paymentMethodId,
customer: customerId, // Include customer ID customer: customerId, // Include customer ID
confirm: true, // Automatically confirm the payment confirm: true, // Automatically confirm the payment
off_session: true, // Indicate this is an off-session payment
return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`, return_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/payment-complete`,
metadata, metadata,
expand: ['charges.data.payment_method_details'], // Expand to get payment method details
}); });
// Extract payment method details from charges
const charge = paymentIntent.charges?.data?.[0];
const paymentMethodDetails = charge?.payment_method_details;
// Build payment method info object
let paymentMethod = null;
if (paymentMethodDetails) {
const type = paymentMethodDetails.type;
if (type === 'card') {
paymentMethod = {
type: 'card',
brand: paymentMethodDetails.card?.brand || 'card',
last4: paymentMethodDetails.card?.last4 || '****',
};
} else if (type === 'us_bank_account') {
paymentMethod = {
type: 'bank',
brand: 'bank_account',
last4: paymentMethodDetails.us_bank_account?.last4 || '****',
};
} else {
paymentMethod = {
type: type || 'unknown',
brand: type || 'payment',
last4: null,
};
}
}
return { return {
paymentIntentId: paymentIntent.id, paymentIntentId: paymentIntent.id,
status: paymentIntent.status, status: paymentIntent.status,
clientSecret: paymentIntent.client_secret, clientSecret: paymentIntent.client_secret,
paymentMethod: paymentMethod,
chargedAt: new Date(paymentIntent.created * 1000), // Convert Unix timestamp to Date
amountCharged: amount, // Original amount in dollars
}; };
} catch (error) { } catch (error) {
console.error("Error charging payment method:", error); console.error("Error charging payment method:", error);
@@ -162,9 +196,6 @@ class StripeService {
mode: 'setup', mode: 'setup',
ui_mode: 'embedded', ui_mode: 'embedded',
redirect_on_completion: 'never', redirect_on_completion: 'never',
setup_intent_data: {
usage: 'off_session'
},
metadata: { metadata: {
type: 'payment_method_setup', type: 'payment_method_setup',
...metadata ...metadata

View File

@@ -0,0 +1,314 @@
<!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>Your First Listing is Live!</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 - Celebration purple gradient */
.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: 16px;
margin-top: 8px;
font-weight: 600;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 22px;
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;
}
/* Celebration box */
.celebration-box {
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.celebration-box p {
margin: 0 0 8px 0;
color: #495057;
}
.celebration-box p:last-child {
margin-bottom: 0;
}
.celebration-icon {
font-size: 32px;
margin-bottom: 10px;
}
/* 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;
color: #155724;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #667eea;
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-box ul {
margin: 10px 0;
padding-left: 20px;
color: #004085;
}
.info-box li {
margin-bottom: 5px;
}
/* Item highlight */
.item-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.item-highlight .item-name {
font-size: 20px;
font-weight: 600;
color: #667eea;
margin-bottom: 10px;
}
/* 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: 24px;
}
.content h2 {
font-size: 20px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.item-highlight .item-name {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">🎉 Your First Listing is Live!</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>Congratulations! You're Now a RentAll Host!</h1>
<p>Your first item is officially live and ready to be rented. This is an exciting milestone!</p>
<div class="item-highlight">
<div class="celebration-icon">🎊</div>
<div class="item-name">{{itemName}}</div>
<p style="color: #6c757d; margin-top: 10px;">
<a href="{{viewItemUrl}}" class="button">View Your Listing</a>
</p>
</div>
<div class="celebration-box">
<p><strong>What happens next?</strong></p>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>Your listing is now searchable by renters</li>
<li>You'll receive email notifications for rental requests</li>
<li>You can approve or decline requests based on your availability</li>
<li>Payments are processed securely through Stripe</li>
</ul>
</div>
<h2>Tips for Success</h2>
<ul>
<li><strong>Respond quickly:</strong> Fast responses lead to more bookings</li>
<li><strong>Keep photos updated:</strong> Great photos attract more renters</li>
<li><strong>Be clear about condition:</strong> Take photos at pickup and return</li>
<li><strong>Communicate well:</strong> Clear communication = happy renters</li>
<li><strong>Maintain availability:</strong> Keep your calendar up to date</li>
</ul>
<div class="success-box">
<p><strong>🌟 Pro Tip:</strong> Hosts who respond within 1 hour get 3x more bookings!</p>
</div>
<p>We're excited to have you as part of the RentAll community. If you have any questions, our support team is here to help.</p>
<p><strong>Happy hosting!</strong><br>
The RentAll Team</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>Building a community of sharing and trust</p>
<p>This email was sent because you created your first listing on RentAll.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,357 @@
<!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 Approved</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, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
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: 0 0 16px 0;
padding-left: 20px;
color: #6c757d;
}
.content li {
margin-bottom: 8px;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #28a745 0%, #20c997 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(40, 167, 69, 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;
color: #155724;
}
.success-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Info box */
.info-box {
background-color: #d1ecf1;
border-left: 4px solid #17a2b8;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 12px 0;
color: #0c5460;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box strong {
color: #0c5460;
}
.info-box ul {
margin: 12px 0 0 0;
padding-left: 20px;
color: #0c5460;
}
.info-box li {
margin-bottom: 8px;
}
/* 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;
color: #856404;
}
.warning-box strong {
color: #856404;
}
/* 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;
}
.info-table .highlight {
font-size: 18px;
font-weight: 700;
color: #28a745;
}
/* 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 Approved</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>You've Approved the Rental Request!</h1>
<div class="success-box">
<div class="icon"></div>
<p><strong>Great news!</strong> You've successfully approved the rental request for <strong>{{itemName}}</strong>.</p>
</div>
<p>The renter has been notified and {{paymentMessage}}</p>
<h2>Rental Details</h2>
<table class="info-table">
<tr>
<th>Item</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Renter</th>
<td>{{renterName}}</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>
{{earningsSection}}
{{stripeSection}}
<h2>What's Next?</h2>
<ul>
<li><strong>Before pickup:</strong> Coordinate with the renter on exact pickup time and location</li>
<li><strong>Take photos:</strong> Document the item's condition before handoff</li>
<li><strong>Share instructions:</strong> Provide any care instructions or usage tips</li>
<li><strong>Stay in touch:</strong> Be available to answer questions during the rental</li>
<li><strong>At return:</strong> Inspect the item and confirm its condition</li>
</ul>
<p style="text-align: center;">
<a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a>
</p>
<p>Thank you for being part of the RentAll community!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This is a transactional email confirming your rental approval. You received this message because you approved a rental request on our platform.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,372 @@
<!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 Complete - Congratulations!</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, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
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: 0 0 16px 0;
padding-left: 20px;
color: #6c757d;
}
.content li {
margin-bottom: 8px;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #28a745 0%, #20c997 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(40, 167, 69, 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 12px 0;
color: #155724;
}
.success-box p:last-child {
margin-bottom: 0;
}
.success-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
.success-box strong {
color: #155724;
}
.success-box a {
color: #155724;
text-decoration: underline;
}
/* Info box */
.info-box {
background-color: #d1ecf1;
border-left: 4px solid #17a2b8;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 12px 0;
color: #0c5460;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box strong {
color: #0c5460;
}
.info-box ul {
margin: 12px 0 0 0;
padding-left: 20px;
color: #0c5460;
}
.info-box li {
margin-bottom: 8px;
}
/* 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 12px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
.warning-box strong {
color: #856404;
}
/* 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;
}
.info-table .highlight {
font-size: 18px;
font-weight: 700;
color: #28a745;
}
/* 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 Complete</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>Congratulations on Completing a Rental!</h1>
<div class="success-box">
<div class="icon"></div>
<p><strong>Rental Complete:</strong> <strong>{{itemName}}</strong> has been successfully returned on time.</p>
<p>Great job maintaining your item and providing excellent service to the renter!</p>
</div>
<h2>Rental Summary</h2>
<table class="info-table">
<tr>
<th>Item</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Renter</th>
<td>{{renterName}}</td>
</tr>
<tr>
<th>Rental Period</th>
<td>{{startDate}} to {{endDate}}</td>
</tr>
<tr>
<th>Returned On</th>
<td>{{returnedDate}}</td>
</tr>
</table>
{{earningsSection}}
{{stripeSection}}
<h2>Tips for Future Rentals</h2>
<ul>
<li><strong>Maintain your items:</strong> Keep them clean and in good condition</li>
<li><strong>Update availability:</strong> Keep your calendar current</li>
<li><strong>Respond quickly:</strong> Fast responses lead to more bookings</li>
<li><strong>Set competitive pricing:</strong> Browse similar items to optimize your rates</li>
<li><strong>Build your reputation:</strong> Consistent quality earns great reviews</li>
</ul>
<h2>List More Items</h2>
<p>Have more items sitting idle? Turn them into income! List camping gear, tools, party supplies, sports equipment, or anything else that others might need.</p>
<p style="text-align: center;">
<a href="{{myListingsUrl}}" class="button">View My Listings</a>
</p>
<p>Thank you for being an excellent host on RentAll!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This email confirms the successful completion of your rental. You received this message because you marked an item as returned on our platform.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,314 @@
<!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>Thank You for Returning On Time!</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, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #d4edda;
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: 0 0 16px 0;
padding-left: 20px;
color: #6c757d;
}
.content li {
margin-bottom: 8px;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #28a745 0%, #20c997 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(40, 167, 69, 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 12px 0;
color: #155724;
}
.success-box p:last-child {
margin-bottom: 0;
}
.success-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Info box */
.info-box {
background-color: #d1ecf1;
border-left: 4px solid #17a2b8;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 12px 0;
color: #0c5460;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box strong {
color: #0c5460;
}
/* 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 Complete</div>
</div>
<div class="content">
<p>Hi {{renterName}},</p>
<h1>Thank You for Returning On Time!</h1>
<div class="success-box">
<div class="icon"></div>
<p><strong>Rental Complete:</strong> You've successfully returned <strong>{{itemName}}</strong> on time.</p>
<p>On-time returns like yours help build trust in the RentAll community. Thank you!</p>
</div>
<h2>Rental Summary</h2>
<table class="info-table">
<tr>
<th>Item</th>
<td>{{itemName}}</td>
</tr>
<tr>
<th>Owner</th>
<td>{{ownerName}}</td>
</tr>
<tr>
<th>Rental Period</th>
<td>{{startDate}} to {{endDate}}</td>
</tr>
<tr>
<th>Returned On</th>
<td>{{returnedDate}}</td>
</tr>
</table>
{{reviewSection}}
<h2>Keep Renting!</h2>
<p>Explore thousands of items available for rent in your area. From tools to camping gear, party supplies to photography equipment - find what you need, when you need it.</p>
<p style="text-align: center;">
<a href="{{browseItemsUrl}}" class="button">Browse Available Items</a>
</p>
<p>Thank you for being a valued member of the RentAll community!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This email confirms the successful completion of your rental. You received this message because you recently returned a rented item on our platform.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -251,6 +251,8 @@
</tr> </tr>
</table> </table>
{{paymentSection}}
<h2>What's next?</h2> <h2>What's next?</h2>
<ul> <ul>
<li><strong>Before pickup:</strong> You'll receive a reminder to take condition photos</li> <li><strong>Before pickup:</strong> You'll receive a reminder to take condition photos</li>

View File

@@ -29,6 +29,8 @@ export interface AutocompletePrediction {
class PlacesService { class PlacesService {
private sessionToken: string | null = null; private sessionToken: string | null = null;
private debounceTimer: NodeJS.Timeout | null = null;
private abortController: AbortController | null = null;
/** /**
* Generate a new session token for cost optimization * Generate a new session token for cost optimization
@@ -41,7 +43,7 @@ class PlacesService {
} }
/** /**
* Get autocomplete predictions for a query * Get autocomplete predictions for a query (debounced)
*/ */
async getAutocompletePredictions( async getAutocompletePredictions(
input: string, input: string,
@@ -55,14 +57,54 @@ class PlacesService {
return []; return [];
} }
// Clear existing debounce timer
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Cancel any pending request
if (this.abortController) {
this.abortController.abort();
}
// Return a promise that resolves after debounce delay
return new Promise((resolve, reject) => {
this.debounceTimer = setTimeout(async () => {
try {
const predictions = await this.makeAutocompleteRequest(
input.trim(),
options
);
resolve(predictions);
} catch (error) {
reject(error);
}
}, 300); // 300ms debounce delay
});
}
/**
* Make the actual autocomplete API request
*/
private async makeAutocompleteRequest(
input: string,
options?: {
types?: string[];
componentRestrictions?: { country: string };
bounds?: any;
}
): Promise<AutocompletePrediction[]> {
// Generate new session token if not exists // Generate new session token if not exists
if (!this.sessionToken) { if (!this.sessionToken) {
this.sessionToken = this.generateSessionToken(); this.sessionToken = this.generateSessionToken();
} }
// Create new abort controller for this request
this.abortController = new AbortController();
try { try {
const response = await mapsAPI.placesAutocomplete({ const response = await mapsAPI.placesAutocomplete({
input: input.trim(), input: input,
types: options?.types || ["address"], types: options?.types || ["address"],
componentRestrictions: options?.componentRestrictions, componentRestrictions: options?.componentRestrictions,
sessionToken: this.sessionToken || undefined, sessionToken: this.sessionToken || undefined,
@@ -77,6 +119,11 @@ class PlacesService {
return []; return [];
} catch (error: any) { } catch (error: any) {
// Don't throw error if request was aborted (user is still typing)
if (error.name === "AbortError" || error.code === "ERR_CANCELED") {
return [];
}
console.error("Error fetching place predictions:", error.message); console.error("Error fetching place predictions:", error.message);
if (error.response?.status === 429) { if (error.response?.status === 429) {
throw new Error("Too many requests. Please slow down."); throw new Error("Too many requests. Please slow down.");