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:
@@ -108,6 +108,15 @@ const Rental = sequelize.define("Rental", {
|
||||
stripePaymentIntentId: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
paymentMethodBrand: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
paymentMethodLast4: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
chargedAt: {
|
||||
type: DataTypes.DATE,
|
||||
},
|
||||
deliveryMethod: {
|
||||
type: DataTypes.ENUM("pickup", "delivery"),
|
||||
defaultValue: "pickup",
|
||||
|
||||
@@ -225,16 +225,37 @@ router.post("/", authenticateToken, requireVerifiedEmail, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
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);
|
||||
reqLogger.info("Item created", {
|
||||
itemId: item.id,
|
||||
ownerId: req.user.id,
|
||||
itemName: req.body.name
|
||||
itemName: req.body.name,
|
||||
isFirstListing: ownerItemCount === 1
|
||||
});
|
||||
|
||||
res.status(201).json(itemWithOwner);
|
||||
|
||||
@@ -69,7 +69,6 @@ const handleServiceError = (error, res, req) => {
|
||||
router.post(
|
||||
"/places/autocomplete",
|
||||
authenticateToken,
|
||||
rateLimiter.burstProtection,
|
||||
rateLimiter.placesAutocomplete,
|
||||
validateInput,
|
||||
async (req, res) => {
|
||||
@@ -150,7 +149,6 @@ router.post(
|
||||
router.post(
|
||||
"/geocode",
|
||||
authenticateToken,
|
||||
rateLimiter.burstProtection,
|
||||
rateLimiter.geocoding,
|
||||
validateInput,
|
||||
async (req, res) => {
|
||||
|
||||
@@ -404,6 +404,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
status: "confirmed",
|
||||
paymentStatus: "paid",
|
||||
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, {
|
||||
@@ -423,7 +426,58 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
return;
|
||||
@@ -464,7 +518,58 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
});
|
||||
|
||||
// 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);
|
||||
return;
|
||||
@@ -958,6 +1063,40 @@ router.post("/:id/mark-return", authenticateToken, async (req, res) => {
|
||||
actualReturnDateTime: actualReturnDateTime || rental.endDateTime,
|
||||
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;
|
||||
|
||||
case "damaged":
|
||||
|
||||
@@ -33,20 +33,24 @@ class EmailService {
|
||||
|
||||
try {
|
||||
const templateFiles = [
|
||||
"conditionCheckReminder.html",
|
||||
"rentalConfirmation.html",
|
||||
"emailVerification.html",
|
||||
"passwordReset.html",
|
||||
"passwordChanged.html",
|
||||
"lateReturnCS.html",
|
||||
"damageReportCS.html",
|
||||
"lostItemCS.html",
|
||||
"rentalRequest.html",
|
||||
"rentalRequestConfirmation.html",
|
||||
"rentalCancellationConfirmation.html",
|
||||
"rentalCancellationNotification.html",
|
||||
"rentalDeclined.html",
|
||||
"payoutReceived.html",
|
||||
"conditionCheckReminderToUser.html",
|
||||
"rentalConfirmationToUser.html",
|
||||
"emailVerificationToUser.html",
|
||||
"passwordResetToUser.html",
|
||||
"passwordChangedToUser.html",
|
||||
"lateReturnToCS.html",
|
||||
"damageReportToCS.html",
|
||||
"lostItemToCS.html",
|
||||
"rentalRequestToOwner.html",
|
||||
"rentalRequestConfirmationToRenter.html",
|
||||
"rentalCancellationConfirmationToUser.html",
|
||||
"rentalCancellationNotificationToUser.html",
|
||||
"rentalDeclinedToRenter.html",
|
||||
"rentalApprovalConfirmationToOwner.html",
|
||||
"rentalCompletionThankYouToRenter.html",
|
||||
"rentalCompletionCongratsToOwner.html",
|
||||
"payoutReceivedToOwner.html",
|
||||
"firstListingCelebrationToOwner.html",
|
||||
];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
@@ -222,7 +226,7 @@ class EmailService {
|
||||
`;
|
||||
|
||||
const templates = {
|
||||
conditionCheckReminder: baseTemplate.replace(
|
||||
conditionCheckReminderToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<h2>{{title}}</h2>
|
||||
@@ -233,7 +237,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
rentalConfirmation: baseTemplate.replace(
|
||||
rentalConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
@@ -245,7 +249,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
emailVerification: baseTemplate.replace(
|
||||
emailVerificationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
@@ -257,7 +261,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
passwordReset: baseTemplate.replace(
|
||||
passwordResetToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
@@ -270,7 +274,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
passwordChanged: baseTemplate.replace(
|
||||
passwordChangedToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
@@ -282,7 +286,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
rentalRequest: baseTemplate.replace(
|
||||
rentalRequestToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
@@ -298,7 +302,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
rentalRequestConfirmation: baseTemplate.replace(
|
||||
rentalRequestConfirmationToRenter: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{renterName}},</p>
|
||||
@@ -314,7 +318,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
rentalCancellationConfirmation: baseTemplate.replace(
|
||||
rentalCancellationConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
@@ -328,7 +332,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
rentalCancellationNotification: baseTemplate.replace(
|
||||
rentalCancellationNotificationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{recipientName}},</p>
|
||||
@@ -343,7 +347,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
payoutReceived: baseTemplate.replace(
|
||||
payoutReceivedToOwner: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{ownerName}},</p>
|
||||
@@ -363,7 +367,7 @@ class EmailService {
|
||||
`
|
||||
),
|
||||
|
||||
rentalDeclined: baseTemplate.replace(
|
||||
rentalDeclinedToRenter: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<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>
|
||||
`
|
||||
),
|
||||
|
||||
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 (
|
||||
@@ -409,7 +467,7 @@ class EmailService {
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"conditionCheckReminder",
|
||||
"conditionCheckReminderToUser",
|
||||
variables
|
||||
);
|
||||
|
||||
@@ -424,7 +482,8 @@ class EmailService {
|
||||
userEmail,
|
||||
notification,
|
||||
rental,
|
||||
recipientName = null
|
||||
recipientName = null,
|
||||
isRenter = false
|
||||
) {
|
||||
const itemName = rental?.item?.name || "Unknown Item";
|
||||
|
||||
@@ -439,9 +498,77 @@ class EmailService {
|
||||
endDate: rental?.endDateTime
|
||||
? new Date(rental.endDateTime).toLocaleDateString()
|
||||
: "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
|
||||
const subject = `Rental Confirmation - ${itemName}`;
|
||||
@@ -458,7 +585,7 @@ class EmailService {
|
||||
verificationUrl: verificationUrl,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("emailVerification", variables);
|
||||
const htmlContent = this.renderTemplate("emailVerificationToUser", variables);
|
||||
|
||||
return await this.sendEmail(
|
||||
user.email,
|
||||
@@ -476,7 +603,7 @@ class EmailService {
|
||||
resetUrl: resetUrl,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("passwordReset", variables);
|
||||
const htmlContent = this.renderTemplate("passwordResetToUser", variables);
|
||||
|
||||
return await this.sendEmail(
|
||||
user.email,
|
||||
@@ -497,7 +624,7 @@ class EmailService {
|
||||
timestamp: timestamp,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("passwordChanged", variables);
|
||||
const htmlContent = this.renderTemplate("passwordChangedToUser", variables);
|
||||
|
||||
return await this.sendEmail(
|
||||
user.email,
|
||||
@@ -554,7 +681,7 @@ class EmailService {
|
||||
approveUrl: approveUrl,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("rentalRequest", variables);
|
||||
const htmlContent = this.renderTemplate("rentalRequestToOwner", variables);
|
||||
|
||||
return await this.sendEmail(
|
||||
owner.email,
|
||||
@@ -608,7 +735,7 @@ class EmailService {
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"rentalRequestConfirmation",
|
||||
"rentalRequestConfirmationToRenter",
|
||||
variables
|
||||
);
|
||||
|
||||
@@ -678,7 +805,7 @@ class EmailService {
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"rentalDeclined",
|
||||
"rentalDeclinedToRenter",
|
||||
variables
|
||||
);
|
||||
|
||||
@@ -730,7 +857,7 @@ class EmailService {
|
||||
earningsDashboardUrl: earningsDashboardUrl,
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate("payoutReceived", variables);
|
||||
const htmlContent = this.renderTemplate("payoutReceivedToOwner", variables);
|
||||
|
||||
return await this.sendEmail(
|
||||
owner.email,
|
||||
@@ -874,7 +1001,7 @@ class EmailService {
|
||||
};
|
||||
|
||||
const confirmationHtml = this.renderTemplate(
|
||||
"rentalCancellationConfirmation",
|
||||
"rentalCancellationConfirmationToUser",
|
||||
confirmationVariables
|
||||
);
|
||||
|
||||
@@ -910,7 +1037,7 @@ class EmailService {
|
||||
};
|
||||
|
||||
const notificationHtml = this.renderTemplate(
|
||||
"rentalCancellationNotification",
|
||||
"rentalCancellationNotificationToUser",
|
||||
notificationVariables
|
||||
);
|
||||
|
||||
@@ -965,7 +1092,7 @@ class EmailService {
|
||||
await this.sendTemplateEmail(
|
||||
process.env.CUSTOMER_SUPPORT_EMAIL,
|
||||
"Late Return Detected - Action Required",
|
||||
"lateReturnCS",
|
||||
"lateReturnToCS",
|
||||
{
|
||||
rentalId: rental.id,
|
||||
itemName: rental.item.name,
|
||||
@@ -1027,7 +1154,7 @@ class EmailService {
|
||||
await this.sendTemplateEmail(
|
||||
process.env.CUSTOMER_SUPPORT_EMAIL,
|
||||
"Damage Report Filed - Action Required",
|
||||
"damageReportCS",
|
||||
"damageReportToCS",
|
||||
{
|
||||
rentalId: rental.id,
|
||||
itemName: rental.item.name,
|
||||
@@ -1086,7 +1213,7 @@ class EmailService {
|
||||
await this.sendTemplateEmail(
|
||||
process.env.CUSTOMER_SUPPORT_EMAIL,
|
||||
"Lost Item Claim Filed - Action Required",
|
||||
"lostItemCS",
|
||||
"lostItemToCS",
|
||||
{
|
||||
rentalId: rental.id,
|
||||
itemName: rental.item.name,
|
||||
@@ -1153,7 +1280,8 @@ class EmailService {
|
||||
owner.email,
|
||||
ownerNotification,
|
||||
rental,
|
||||
owner.firstName
|
||||
owner.firstName,
|
||||
false // isRenter = false for owner
|
||||
);
|
||||
if (ownerResult.success) {
|
||||
console.log(
|
||||
@@ -1181,7 +1309,8 @@ class EmailService {
|
||||
renter.email,
|
||||
renterNotification,
|
||||
rental,
|
||||
renter.firstName
|
||||
renter.firstName,
|
||||
true // isRenter = true for renter (enables payment receipt)
|
||||
);
|
||||
if (renterResult.success) {
|
||||
console.log(
|
||||
@@ -1210,6 +1339,391 @@ class EmailService {
|
||||
|
||||
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();
|
||||
|
||||
@@ -123,14 +123,48 @@ class StripeService {
|
||||
payment_method: paymentMethodId,
|
||||
customer: customerId, // Include customer ID
|
||||
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`,
|
||||
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 {
|
||||
paymentIntentId: paymentIntent.id,
|
||||
status: paymentIntent.status,
|
||||
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) {
|
||||
console.error("Error charging payment method:", error);
|
||||
@@ -162,9 +196,6 @@ class StripeService {
|
||||
mode: 'setup',
|
||||
ui_mode: 'embedded',
|
||||
redirect_on_completion: 'never',
|
||||
setup_intent_data: {
|
||||
usage: 'off_session'
|
||||
},
|
||||
metadata: {
|
||||
type: 'payment_method_setup',
|
||||
...metadata
|
||||
|
||||
314
backend/templates/emails/firstListingCelebrationToOwner.html
Normal file
314
backend/templates/emails/firstListingCelebrationToOwner.html
Normal 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>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
357
backend/templates/emails/rentalApprovalConfirmationToOwner.html
Normal file
357
backend/templates/emails/rentalApprovalConfirmationToOwner.html
Normal 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>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
372
backend/templates/emails/rentalCompletionCongratsToOwner.html
Normal file
372
backend/templates/emails/rentalCompletionCongratsToOwner.html
Normal 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>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
314
backend/templates/emails/rentalCompletionThankYouToRenter.html
Normal file
314
backend/templates/emails/rentalCompletionThankYouToRenter.html
Normal 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>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -251,6 +251,8 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{paymentSection}}
|
||||
|
||||
<h2>What's next?</h2>
|
||||
<ul>
|
||||
<li><strong>Before pickup:</strong> You'll receive a reminder to take condition photos</li>
|
||||
@@ -29,6 +29,8 @@ export interface AutocompletePrediction {
|
||||
|
||||
class PlacesService {
|
||||
private sessionToken: string | null = null;
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* 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(
|
||||
input: string,
|
||||
@@ -55,14 +57,54 @@ class PlacesService {
|
||||
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
|
||||
if (!this.sessionToken) {
|
||||
this.sessionToken = this.generateSessionToken();
|
||||
}
|
||||
|
||||
// Create new abort controller for this request
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.placesAutocomplete({
|
||||
input: input.trim(),
|
||||
input: input,
|
||||
types: options?.types || ["address"],
|
||||
componentRestrictions: options?.componentRestrictions,
|
||||
sessionToken: this.sessionToken || undefined,
|
||||
@@ -77,6 +119,11 @@ class PlacesService {
|
||||
|
||||
return [];
|
||||
} 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);
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Too many requests. Please slow down.");
|
||||
|
||||
Reference in New Issue
Block a user