Compare commits

..

4 Commits

Author SHA1 Message Date
jackiettran
07e5a2a320 Rebrand and updated copyright date 2025-12-22 22:35:57 -05:00
jackiettran
955517347e health endpoint 2025-12-20 15:21:33 -05:00
jackiettran
bd1bd5014c updating unit and integration tests 2025-12-20 14:59:09 -05:00
jackiettran
4e0a4ef019 updated upload unit tests for s3 image handling 2025-12-19 18:58:30 -05:00
65 changed files with 6821 additions and 3522 deletions

View File

@@ -1,5 +1,22 @@
module.exports = {
testEnvironment: 'node',
projects: [
{
displayName: 'unit',
testEnvironment: 'node',
testMatch: ['**/tests/unit/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
testTimeout: 10000,
},
{
displayName: 'integration',
testEnvironment: 'node',
testMatch: ['**/tests/integration/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/integration-setup.js'],
testTimeout: 30000,
},
],
// Run tests sequentially to avoid module cache conflicts between unit and integration tests
maxWorkers: 1,
coverageDirectory: 'coverage',
collectCoverageFrom: [
'**/*.js',
@@ -9,10 +26,6 @@ module.exports = {
'!jest.config.js'
],
coverageReporters: ['text', 'lcov', 'html'],
testMatch: ['**/tests/**/*.test.js'],
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
forceExit: true,
testTimeout: 10000,
coverageThreshold: {
global: {
lines: 80,

View File

@@ -12,10 +12,10 @@
"dev:qa": "NODE_ENV=qa nodemon -r dotenv/config server.js dotenv_config_path=.env.qa",
"test": "NODE_ENV=test jest",
"test:watch": "NODE_ENV=test jest --watch",
"test:coverage": "jest --coverage --forceExit --maxWorkers=4",
"test:coverage": "jest --coverage --maxWorkers=1",
"test:unit": "NODE_ENV=test jest tests/unit",
"test:integration": "NODE_ENV=test jest tests/integration",
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2",
"test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=1",
"db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",

View File

@@ -553,7 +553,7 @@ router.post(
}
// Validate the code
if (!user.isVerificationTokenValid(input)) {
if (!user.isVerificationTokenValid(code)) {
// Increment failed attempts
await user.incrementVerificationAttempts();

121
backend/routes/health.js Normal file
View File

@@ -0,0 +1,121 @@
const express = require("express");
const router = express.Router();
const { sequelize } = require("../models");
const s3Service = require("../services/s3Service");
const logger = require("../utils/logger");
/**
* Health check endpoint for load balancers and monitoring
* GET /health
*
* Returns:
* - 200: All services healthy
* - 503: One or more services unhealthy
*/
router.get("/", async (req, res) => {
const startTime = Date.now();
const checks = {
database: { status: "unknown", latency: null },
s3: { status: "unknown", latency: null },
};
let allHealthy = true;
// Database health check
try {
const dbStart = Date.now();
await sequelize.authenticate();
checks.database = {
status: "healthy",
latency: Date.now() - dbStart,
};
} catch (error) {
allHealthy = false;
checks.database = {
status: "unhealthy",
error: error.message,
latency: Date.now() - startTime,
};
logger.error("Health check: Database connection failed", {
error: error.message,
});
}
// S3 health check (if enabled)
if (s3Service.isEnabled()) {
try {
const s3Start = Date.now();
// S3 is considered healthy if it's properly initialized
// A more thorough check could list bucket contents, but that adds latency
checks.s3 = {
status: "healthy",
latency: Date.now() - s3Start,
bucket: process.env.S3_BUCKET,
};
} catch (error) {
allHealthy = false;
checks.s3 = {
status: "unhealthy",
error: error.message,
latency: Date.now() - startTime,
};
logger.error("Health check: S3 check failed", {
error: error.message,
});
}
} else {
checks.s3 = {
status: "disabled",
latency: 0,
};
}
// Log unhealthy states
if (!allHealthy) {
logger.warn("Health check failed", { checks });
}
res.status(allHealthy ? 200 : 503).json({
status: allHealthy ? "healthy" : "unhealthy",
});
});
/**
* Liveness probe - simple check that the process is running
* GET /health/live
*
* Used by Kubernetes/ECS for liveness probes
* Returns 200 if the process is alive
*/
router.get("/live", (req, res) => {
res.status(200).json({
status: "alive",
timestamp: new Date().toISOString(),
});
});
/**
* Readiness probe - check if the service is ready to accept traffic
* GET /health/ready
*
* Used by load balancers to determine if instance should receive traffic
* Checks critical dependencies (database)
*/
router.get("/ready", async (req, res) => {
try {
await sequelize.authenticate();
res.status(200).json({
status: "ready",
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error("Readiness check failed", { error: error.message });
res.status(503).json({
status: "not_ready",
timestamp: new Date().toISOString(),
error: "Database connection failed",
});
}
});
module.exports = router;

View File

@@ -29,6 +29,7 @@ const mapsRoutes = require("./routes/maps");
const conditionCheckRoutes = require("./routes/conditionChecks");
const feedbackRoutes = require("./routes/feedback");
const uploadRoutes = require("./routes/upload");
const healthRoutes = require("./routes/health");
const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
@@ -142,15 +143,18 @@ app.use(
express.static(path.join(__dirname, "uploads"))
);
// Health check endpoints (no auth, no rate limiting)
app.use("/health", healthRoutes);
// Root endpoint
app.get("/", (req, res) => {
res.json({ message: "Village Share API is running!" });
});
// Public routes (no alpha access required)
app.use("/api/alpha", alphaRoutes);
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
// Health check endpoint
app.get("/", (req, res) => {
res.json({ message: "CommunityRentals.App API is running!" });
});
// Protected routes (require alpha access)
app.use("/api/users", requireAlphaAccess, userRoutes);
app.use("/api/items", requireAlphaAccess, itemRoutes);

View File

@@ -79,7 +79,7 @@ class EmailClient {
}
// Use friendly sender name format for better recognition
const fromName = process.env.SES_FROM_NAME || "RentAll";
const fromName = process.env.SES_FROM_NAME || "Village Share";
const fromEmail = process.env.SES_FROM_EMAIL;
const source = `${fromName} <${fromEmail}>`;

View File

@@ -219,13 +219,13 @@ class TemplateManager {
<body>
<div class="container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
</div>
<div class="content">
{{content}}
</div>
<div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p>
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
</div>
</div>
</body>
@@ -252,7 +252,7 @@ class TemplateManager {
<p>{{message}}</p>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
<p>Thank you for using RentAll!</p>
<p>Thank you for using Village Share!</p>
`
),
@@ -261,7 +261,7 @@ class TemplateManager {
`
<p>Hi {{recipientName}},</p>
<h2>Verify Your Email Address</h2>
<p>Thank you for registering with RentAll! Please verify your email address by clicking the button below.</p>
<p>Thank you for registering with Village Share! Please verify your email address by clicking the button below.</p>
<p><a href="{{verificationUrl}}" class="button">Verify Email Address</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{verificationUrl}}</p>
<p><strong>This link will expire in 24 hours.</strong></p>
@@ -273,7 +273,7 @@ class TemplateManager {
`
<p>Hi {{recipientName}},</p>
<h2>Reset Your Password</h2>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
<p>We received a request to reset the password for your Village Share account. Click the button below to choose a new password.</p>
<p><a href="{{resetUrl}}" class="button">Reset Password</a></p>
<p>If the button doesn't work, copy and paste this link into your browser: {{resetUrl}}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
@@ -286,7 +286,7 @@ class TemplateManager {
`
<p>Hi {{recipientName}},</p>
<h2>Your Password Has Been Changed</h2>
<p>This is a confirmation that the password for your RentAll account ({{email}}) has been successfully changed.</p>
<p>This is a confirmation that the password for your Village Share account ({{email}}) has been successfully changed.</p>
<p><strong>Changed on:</strong> {{timestamp}}</p>
<p>For your security, all existing sessions have been logged out.</p>
<p><strong>Didn't change your password?</strong> If you did not make this change, please contact our support team immediately.</p>
@@ -370,7 +370,7 @@ class TemplateManager {
<p style="font-size: 18px; color: #28a745;"><strong>Your Earnings:</strong> \${{payoutAmount}}</p>
<p>Funds are typically available in your bank account within 2-3 business days.</p>
<p><a href="{{earningsDashboardUrl}}" class="button">View Earnings Dashboard</a></p>
<p>Thank you for being a valued member of the RentAll community!</p>
<p>Thank you for being a valued member of the Village Share community!</p>
`
),
@@ -389,7 +389,7 @@ class TemplateManager {
<div class="info-box">
<p><strong>What happens next?</strong></p>
<p>{{paymentMessage}}</p>
<p>We encourage you to explore other similar items available for rent on RentAll. There are many great options waiting for you!</p>
<p>We encourage you to explore other similar items available for rent on Village Share. There are many great options waiting for you!</p>
</div>
<p style="text-align: center;"><a href="{{browseItemsUrl}}" class="button">Browse Available Items</a></p>
<p>If you have any questions or concerns, please don't hesitate to contact our support team.</p>
@@ -424,7 +424,7 @@ class TemplateManager {
`
<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>
<p>You've successfully returned <strong>{{itemName}}</strong> on time. On-time returns like yours help build trust in the Village Share community!</p>
<h3>Rental Summary</h3>
<p><strong>Item:</strong> {{itemName}}</p>
<p><strong>Rental Period:</strong> {{startDate}} to {{endDate}}</p>
@@ -460,7 +460,7 @@ class TemplateManager {
{{feedbackText}}
</div>
<p><strong>Submitted:</strong> {{submittedAt}}</p>
<p>Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>Your input helps us improve Village Share for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>If your feedback requires a response, our team will reach out to you directly.</p>
`
),

View File

@@ -58,7 +58,7 @@ class AlphaInvitationEmailService {
return await this.emailClient.sendEmail(
email,
"Your Alpha Access Code - RentAll",
"Your Alpha Access Code - Village Share",
htmlContent
);
} catch (error) {

View File

@@ -60,7 +60,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail(
user.email,
"Verify Your Email - RentAll",
"Verify Your Email - Village Share",
htmlContent
);
}
@@ -93,7 +93,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail(
user.email,
"Reset Your Password - RentAll",
"Reset Your Password - Village Share",
htmlContent
);
}
@@ -128,7 +128,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail(
user.email,
"Password Changed Successfully - RentAll",
"Password Changed Successfully - Village Share",
htmlContent
);
}
@@ -163,7 +163,7 @@ class AuthEmailService {
return await this.emailClient.sendEmail(
user.email,
"Personal Information Updated - RentAll",
"Personal Information Updated - Village Share",
htmlContent
);
}

View File

@@ -65,7 +65,7 @@ class FeedbackEmailService {
return await this.emailClient.sendEmail(
user.email,
"Thank You for Your Feedback - RentAll",
"Thank You for Your Feedback - Village Share",
htmlContent
);
}

View File

@@ -941,7 +941,7 @@ class RentalFlowEmailService {
<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>
<p>Your feedback helps other renters make informed decisions and supports quality listings on Village Share.</p>
<ul>
<li>How was the item's condition?</li>
<li>Was the owner responsive and helpful?</li>
@@ -956,7 +956,7 @@ class RentalFlowEmailService {
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>
<p>Your feedback has been submitted and helps strengthen the Village Share community.</p>
</div>
`;
}

View File

@@ -64,7 +64,7 @@ class RentalReminderEmailService {
return await this.emailClient.sendEmail(
userEmail,
`RentAll: ${notification.title}`,
`Village Share: ${notification.title}`,
htmlContent
);
} catch (error) {

View File

@@ -60,7 +60,7 @@ class UserEngagementEmailService {
variables
);
const subject = `Congratulations! Your first item is live on RentAll`;
const subject = `Congratulations! Your first item is live on Village Share`;
return await this.emailClient.sendEmail(
owner.email,

View File

@@ -4,7 +4,7 @@
<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 Alpha Access Code - RentAll</title>
<title>Your Alpha Access Code - Village Share</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
@@ -220,14 +220,14 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Alpha Access Invitation</div>
</div>
<div class="content">
<h1>Welcome to Alpha Testing!</h1>
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for RentAll, the community-powered rental marketplace.</p>
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for Village Share, the community-powered rental marketplace.</p>
<p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p>
@@ -244,7 +244,7 @@
</div>
<div style="text-align: center;">
<a href="{{frontendUrl}}" class="button">Access RentAll Alpha</a>
<a href="{{frontendUrl}}" class="button">Access Village Share Alpha</a>
</div>
<p><strong>What to expect as an alpha tester:</strong></p>
@@ -266,7 +266,7 @@
<li>We value your feedback - let us know what you think!</li>
</ul>
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making RentAll the best it can be.</p>
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making Village Share the best it can be.</p>
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p>
@@ -274,9 +274,9 @@
</div>
<div class="footer">
<p><strong>RentAll Alpha Testing Program</strong></p>
<p>Need help? Contact us at <a href="mailto:support@rentall.app">support@rentall.app</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p><strong>Village Share Alpha Testing Program</strong></p>
<p>Need help? Contact us at <a href="mailto:support@villageshare.app">support@villageshare.app</a></p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -1,241 +1,269 @@
<!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">
<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>{{title}}</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;
}
/* 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;
}
/* 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 */
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #1565c0;
}
.info-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #856404;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #1565c0;
}
.info-box .icon {
font-size: 24px;
margin-bottom: 10px;
}
/* Alert box */
.alert-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0;
color: #856404;
}
/* 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;
}
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Your trusted rental marketplace</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Your trusted rental marketplace</div>
</div>
<div class="content">
<h1>📸 {{title}}</h1>
<p>{{message}}</p>
<div class="info-box">
<div class="icon">📦</div>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
</div>
<div class="content">
<h1>📸 {{title}}</h1>
<p>
Taking condition photos helps protect both renters and owners by
providing clear documentation of the item's state. This is an
important step in the rental process.
</p>
<p>{{message}}</p>
<div class="info-box">
<div class="icon">📦</div>
<p><strong>Rental Item:</strong> {{itemName}}</p>
<p><strong>Deadline:</strong> {{deadline}}</p>
</div>
<p>Taking condition photos helps protect both renters and owners by providing clear documentation of the item's state. This is an important step in the rental process.</p>
<div class="alert-box">
<p><strong>Important:</strong> Please complete this condition check as soon as possible. Missing this deadline may affect dispute resolution if issues arise.</p>
</div>
<a href="#" class="button">Complete Condition Check</a>
<h2>What to photograph:</h2>
<ul>
<li>Overall view of the item</li>
<li>Any existing damage or wear</li>
<li>Serial numbers or identifying marks</li>
<li>Accessories or additional components</li>
</ul>
<p>If you have any questions about the condition check process, please don't hesitate to contact our support team.</p>
<div class="alert-box">
<p>
<strong>Important:</strong> Please complete this condition check as
soon as possible. Missing this deadline may affect dispute
resolution if issues arise.
</p>
</div>
<div class="footer">
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>You received this email because you have an active rental on RentAll.</p>
<p>If you have any questions, please <a href="mailto:support@rentall.com">contact our support team</a>.</p>
</div>
<a href="#" class="button">Complete Condition Check</a>
<h2>What to photograph:</h2>
<ul>
<li>Overall view of the item</li>
<li>Any existing damage or wear</li>
<li>Serial numbers or identifying marks</li>
<li>Accessories or additional components</li>
</ul>
<p>
If you have any questions about the condition check process, please
don't hesitate to contact our support team.
</p>
</div>
<div class="footer">
<p>&copy; 2025 Village Share. All rights reserved.</p>
<p>
You received this email because you have an active rental on Village
Share.
</p>
<p>
If you have any questions, please
<a href="mailto:support@villageshare.app">contact our support team</a
>.
</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,249 +1,292 @@
<!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>Verify Your Email - RentAll</title>
<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>Verify Your Email - Village Share</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;
}
/* 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;
}
/* 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 */
/* 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 p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
font-size: 14px;
}
/* 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;
}
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Email Verification</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Email Verification</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Verify Your Email Address</h1>
<p>
Thank you for registering with Village Share! Use the verification
code below to complete your account setup.
</p>
<!-- Verification Code Display -->
<div style="text-align: center; margin: 30px 0">
<p style="margin-bottom: 10px; color: #6c757d; font-size: 14px">
Your verification code is:
</p>
<div
style="
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
padding: 20px 40px;
display: inline-block;
border: 2px dashed #28a745;
"
>
<span
style="
font-size: 36px;
font-weight: 700;
letter-spacing: 8px;
color: #28a745;
font-family: 'Courier New', monospace;
"
>{{verificationCode}}</span
>
</div>
<p style="margin-top: 10px; font-size: 14px; color: #6c757d">
Enter this code in the app to verify your email
</p>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Verify Your Email Address</h1>
<p>Thank you for registering with RentAll! Use the verification code below to complete your account setup.</p>
<!-- Verification Code Display -->
<div style="text-align: center; margin: 30px 0;">
<p style="margin-bottom: 10px; color: #6c757d; font-size: 14px;">Your verification code is:</p>
<div style="background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 12px;
padding: 20px 40px;
display: inline-block;
border: 2px dashed #28a745;">
<span style="font-size: 36px;
font-weight: 700;
letter-spacing: 8px;
color: #28a745;
font-family: 'Courier New', monospace;">{{verificationCode}}</span>
</div>
<p style="margin-top: 10px; font-size: 14px; color: #6c757d;">
Enter this code in the app to verify your email
</p>
</div>
<div style="text-align: center; margin: 20px 0;">
<p style="color: #6c757d; margin-bottom: 10px; font-size: 14px;">Or click the button below:</p>
<a href="{{verificationUrl}}" class="button">Verify Email Address</a>
</div>
<div class="info-box">
<p><strong>Why verify?</strong> Email verification helps us ensure account security and allows you to create listings, make rentals, and process payments.</p>
</div>
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #667eea;">{{verificationUrl}}</p>
<div class="warning-box">
<p><strong>This code will expire in 24 hours.</strong> If you need a new verification code, you can request one from your account settings.</p>
</div>
<p><strong>Didn't create an account?</strong> If you didn't register for a RentAll account, you can safely ignore this email.</p>
<p>Welcome to the RentAll community!</p>
<div style="text-align: center; margin: 20px 0">
<p style="color: #6c757d; margin-bottom: 10px; font-size: 14px">
Or click the button below:
</p>
<a href="{{verificationUrl}}" class="button">Verify Email Address</a>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This is a transactional email to verify your account. You received this message because an account was created with this email address.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="info-box">
<p>
<strong>Why verify?</strong> Email verification helps us ensure
account security and allows you to create listings, make rentals,
and process payments.
</p>
</div>
<p>
If the button doesn't work, you can copy and paste this link into your
browser:
</p>
<p style="word-break: break-all; color: #667eea">{{verificationUrl}}</p>
<div class="warning-box">
<p>
<strong>This code will expire in 24 hours.</strong> If you need a
new verification code, you can request one from your account
settings.
</p>
</div>
<p>
<strong>Didn't create an account?</strong> If you didn't register for
a Village Share account, you can safely ignore this email.
</p>
<p>Welcome to the Village Share community!</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a transactional email to verify your account. You received
this message because an account was created with this email address.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feedback Received - RentAll</title>
<title>Feedback Received - Village Share</title>
<style>
body {
font-family: Arial, sans-serif;
@@ -84,7 +84,7 @@
<body>
<div class="container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
</div>
<div class="content">
<p>Hi {{userName}},</p>
@@ -102,15 +102,15 @@
<p><strong>Submitted:</strong> {{submittedAt}}</p>
<p>Your input helps us improve RentAll for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>Your input helps us improve Village Share for everyone. We take all feedback seriously and use it to make the platform better.</p>
<p>If your feedback requires a response, our team will reach out to you directly at <strong>{{userEmail}}</strong>.</p>
<p>Want to share more thoughts? Feel free to send us additional feedback anytime through the app.</p>
</div>
<div class="footer">
<p>This email was sent from RentAll. If you have any questions, please contact support.</p>
<p>&copy; {{year}} RentAll. All rights reserved.</p>
<p>This email was sent from Village Share. If you have any questions, please contact support.</p>
<p>&copy; {{year}} Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>New Feedback Received - RentAll</title>
<title>New Feedback Received - Village Share</title>
<style>
body {
font-family: Arial, sans-serif;
@@ -99,7 +99,7 @@
<body>
<div class="container">
<div class="header">
<div class="logo">RentAll Admin</div>
<div class="logo">Village Share Admin</div>
</div>
<div class="content">
<div class="alert-box">
@@ -151,8 +151,8 @@
<p><strong>Action Required:</strong> Please review this feedback and take appropriate action. If a response is needed, contact the user directly at <strong>{{userEmail}}</strong>.</p>
</div>
<div class="footer">
<p>This is an automated notification from RentAll Feedback System</p>
<p>&copy; {{year}} RentAll. All rights reserved.</p>
<p>This is an automated notification from Village Share Feedback System</p>
<p>&copy; {{year}} Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -1,314 +1,355 @@
<!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">
<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;
}
/* 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;
}
/* 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 */
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header - Celebration purple gradient */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 24px;
}
.content h2 {
font-size: 22px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
font-size: 20px;
}
.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;
display: block;
width: 100%;
box-sizing: border-box;
}
.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;
}
font-size: 18px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">🎉 Your First Listing is Live!</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">🎉 Your First Listing is Live!</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>Congratulations! You're Now a Village Share 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="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 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>
<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>
<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 Village Share community. If
you have any questions, our support team is here to help.
</p>
<p>
<strong>Happy hosting!</strong><br />
The Village Share Team
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p>
<p>
This email was sent because you created your first listing on Village
Share.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,312 +1,332 @@
<!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">
<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 Comment Was Marked as the Answer</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;
}
/* 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;
}
/* 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 */
/* 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;
}
/* 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: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
border-left: 4px solid #28a745;
padding: 25px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
text-align: center;
}
.success-box .icon {
font-size: 48px;
margin-bottom: 10px;
}
.success-box h2 {
font-size: 20px;
font-weight: 600;
color: #155724;
margin: 0 0 10px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 15px;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment box */
.comment-box {
background-color: #f8f9fa;
border: 2px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
position: relative;
}
.comment-box .badge {
position: absolute;
top: -12px;
right: 20px;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: #ffffff;
padding: 6px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.comment-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Info box */
.info-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #856404;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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;
display: block;
width: 100%;
box-sizing: border-box;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Success box */
.success-box {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
border-left: 4px solid #28a745;
padding: 25px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
text-align: center;
padding: 20px;
}
.success-box .icon {
font-size: 48px;
margin-bottom: 10px;
font-size: 36px;
}
.success-box h2 {
font-size: 20px;
font-weight: 600;
color: #155724;
margin: 0 0 10px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 15px;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment box */
.comment-box {
background-color: #f8f9fa;
border: 2px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
position: relative;
}
.comment-box .badge {
position: absolute;
top: -12px;
right: 20px;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: #ffffff;
padding: 6px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.comment-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Info box */
.info-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #856404;
}
/* 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;
}
.success-box {
padding: 20px;
}
.success-box .icon {
font-size: 36px;
}
.comment-box {
padding: 15px;
}
padding: 15px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Forum Recognition</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Forum Recognition</div>
</div>
<div class="content">
<p>Hi {{commentAuthorName}},</p>
<h1>Your comment was marked as the accepted answer!</h1>
<div class="success-box">
<div class="icon"></div>
<h2>Great job helping the community!</h2>
<p>{{postAuthorName}} marked your comment as the accepted answer</p>
</div>
<div class="content">
<p>Hi {{commentAuthorName}},</p>
<p>Your helpful comment successfully answered this question:</p>
<h1>Your comment was marked as the accepted answer!</h1>
<div class="success-box">
<div class="icon"></div>
<h2>Great job helping the community!</h2>
<p>{{postAuthorName}} marked your comment as the accepted answer</p>
</div>
<p>Your helpful comment successfully answered this question:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="comment-box">
<div class="badge">Accepted Answer</div>
<div class="content-text">{{commentContent}}</div>
</div>
<a href="{{postUrl}}" class="button">View Post</a>
<p>Thank you for contributing your knowledge and helping others in the RentAll community!</p>
<div class="info-box">
<p><strong>Keep it up!</strong> Your contributions make RentAll a better place for everyone. Continue sharing your expertise and helping fellow community members.</p>
</div>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because your forum comment was marked as the accepted answer.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="comment-box">
<div class="badge">Accepted Answer</div>
<div class="content-text">{{commentContent}}</div>
</div>
<a href="{{postUrl}}" class="button">View Post</a>
<p>
Thank you for contributing your knowledge and helping others in the
Village Share community!
</p>
<div class="info-box">
<p>
<strong>Keep it up!</strong> Your contributions make Village Share a
better place for everyone. Continue sharing your expertise and
helping fellow community members.
</p>
</div>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
You received this email because your forum comment was marked as the
accepted answer.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,314 +1,350 @@
<!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">
<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 Forum Comment Has Been Removed</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;
}
/* 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;
}
/* 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 */
/* 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 - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
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;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Post highlight */
.post-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
}
.post-highlight .post-title {
font-size: 18px;
font-weight: 600;
color: #495057;
margin-bottom: 5px;
}
.post-highlight .post-link {
font-size: 14px;
color: #667eea;
text-decoration: none;
}
.post-highlight .post-link:hover {
text-decoration: underline;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #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;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 16px;
margin-top: 8px;
font-weight: 600;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 24px;
}
.content h2 {
font-size: 22px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
font-size: 20px;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Post highlight */
.post-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.post-highlight .post-title {
font-size: 18px;
font-weight: 600;
color: #495057;
margin-bottom: 5px;
}
.post-highlight .post-link {
font-size: 14px;
color: #667eea;
text-decoration: none;
}
.post-highlight .post-link:hover {
text-decoration: underline;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #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;
}
/* 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;
}
.post-highlight .post-title {
font-size: 16px;
}
font-size: 16px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">⚠️ Important: Comment Removal Notice</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">⚠️ Important: Comment Removal Notice</div>
</div>
<div class="content">
<p>Hi {{commentAuthorName}},</p>
<h1>Your Comment Has Been Removed</h1>
<p>
We're writing to inform you that your comment has been removed from a
forum discussion by {{adminName}}.
</p>
<div class="post-highlight">
<p style="margin: 0 0 10px 0; color: #6c757d; font-size: 14px">
Comment on:
</p>
<div class="post-title">{{postTitle}}</div>
<a href="{{postUrl}}" class="post-link">View Discussion →</a>
</div>
<div class="content">
<p>Hi {{commentAuthorName}},</p>
<h1>Your Comment Has Been Removed</h1>
<p>We're writing to inform you that your comment has been removed from a forum discussion by {{adminName}}.</p>
<div class="post-highlight">
<p style="margin: 0 0 10px 0; color: #6c757d; font-size: 14px;">Comment on:</p>
<div class="post-title">{{postTitle}}</div>
<a href="{{postUrl}}" class="post-link">View Discussion →</a>
</div>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</p>
</div>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;">
<li>Your comment is no longer visible to other community members</li>
<li>The comment content has been preserved in case of appeal</li>
<li>The discussion thread remains active for other participants</li>
<li>You can still participate in other forum discussions</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>If you believe this removal was made in error or if you have questions about our community guidelines, please don't hesitate to contact our support team:</p>
<p style="text-align: center;">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Community Guidelines:</strong></p>
<p>To ensure a positive experience for all members, please review our community guidelines. We appreciate respectful, constructive contributions that help build a supportive community.</p>
</div>
<p>Thank you for your understanding, and we look forward to your continued participation in our community.</p>
<p><strong>Best regards,</strong><br>
The RentAll Team</p>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</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 your comment was removed by our moderation team.</p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>
Your comment is no longer visible to other community members
</li>
<li>The comment content has been preserved in case of appeal</li>
<li>The discussion thread remains active for other participants</li>
<li>You can still participate in other forum discussions</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>
If you believe this removal was made in error or if you have questions
about our community guidelines, please don't hesitate to contact our
support team:
</p>
<p style="text-align: center">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Community Guidelines:</strong></p>
<p>
To ensure a positive experience for all members, please review our
community guidelines. We appreciate respectful, constructive
contributions that help build a supportive community.
</p>
</div>
<p>
Thank you for your understanding, and we look forward to your
continued participation in our community.
</p>
<p>
<strong>Best regards,</strong><br />
The Village Share Team
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p>
<p>
This email was sent because your comment was removed by our moderation
team.
</p>
<p>
If you have questions, please contact
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,271 +1,289 @@
<!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">
<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>New Comment on Your Post</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;
}
/* 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;
}
/* 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 */
/* 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;
}
/* 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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment box */
.comment-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.comment-box .author {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 10px 0;
}
.comment-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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;
display: block;
width: 100%;
box-sizing: border-box;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment box */
.comment-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.comment-box .author {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 10px 0;
}
.comment-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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;
}
.comment-box {
padding: 15px;
}
padding: 15px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Forum Activity</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Forum Activity</div>
</div>
<div class="content">
<p>Hi {{postAuthorName}},</p>
<h1>{{commenterName}} commented on your post</h1>
<p>Someone just commented on your forum post:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="content">
<p>Hi {{postAuthorName}},</p>
<h1>{{commenterName}} commented on your post</h1>
<p>Someone just commented on your forum post:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="comment-box">
<div class="author">{{commenterName}}</div>
<div class="content-text">{{commentContent}}</div>
<div class="timestamp">Posted {{timestamp}}</div>
</div>
<a href="{{postUrl}}" class="button">View Post & Reply</a>
<p>Click the button above to see the full discussion and respond to this comment.</p>
<div class="info-box">
<p><strong>Tip:</strong> Engaging with commenters helps build a vibrant community and provides better answers for everyone.</p>
</div>
<div class="comment-box">
<div class="author">{{commenterName}}</div>
<div class="content-text">{{commentContent}}</div>
<div class="timestamp">Posted {{timestamp}}</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because someone commented on your forum post.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<a href="{{postUrl}}" class="button">View Post & Reply</a>
<p>
Click the button above to see the full discussion and respond to this
comment.
</p>
<div class="info-box">
<p>
<strong>Tip:</strong> Engaging with commenters helps build a vibrant
community and provides better answers for everyone.
</p>
</div>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
You received this email because someone commented on your forum post.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -240,7 +240,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Item Request Near You</div>
</div>
@@ -282,13 +282,13 @@
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
You received this email because someone near you posted an item
request.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -1,266 +1,286 @@
<!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">
<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>Discussion Closed</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;
}
/* 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;
}
/* 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 */
/* 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, #6c757d 0%, #495057 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #6c757d 0%, #495057 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(108, 117, 125, 0.4);
}
/* 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;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #6c757d;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Info box */
.info-box {
background-color: #e9ecef;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.info-box .label {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 5px 0;
}
.info-box .value {
color: #212529;
margin: 0;
}
.info-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #6c757d 0%, #495057 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;
display: block;
width: 100%;
box-sizing: border-box;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
/* 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;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #6c757d;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Info box */
.info-box {
background-color: #e9ecef;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.info-box .label {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 5px 0;
}
.info-box .value {
color: #212529;
margin: 0;
}
.info-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 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: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-box {
padding: 15px;
}
padding: 15px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Forum Notification</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Forum Notification</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>A discussion has been closed</h1>
<p>The following forum discussion has been closed:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>A discussion has been closed</h1>
<p>The following forum discussion has been closed:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="info-box">
<div class="label">Closed by</div>
<div class="value">{{adminName}}</div>
<div class="timestamp">{{timestamp}}</div>
</div>
<div class="warning-box">
<p><strong>Note:</strong> This discussion is now closed and no new comments can be added. You can still view the existing discussion and all previous comments.</p>
</div>
<a href="{{postUrl}}" class="button">View Discussion</a>
<p>If you have questions about this closure, you can reach out to the person who closed it or contact our support team.</p>
<div class="info-box">
<div class="label">Closed by</div>
<div class="value">{{adminName}}</div>
<div class="timestamp">{{timestamp}}</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because you participated in or authored this forum discussion.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="warning-box">
<p>
<strong>Note:</strong> This discussion is now closed and no new
comments can be added. You can still view the existing discussion
and all previous comments.
</p>
</div>
<a href="{{postUrl}}" class="button">View Discussion</a>
<p>
If you have questions about this closure, you can reach out to the
person who closed it or contact our support team.
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
You received this email because you participated in or authored this
forum discussion.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,305 +1,340 @@
<!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">
<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 Forum Post Has Been Removed</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;
}
/* 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;
}
/* 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 */
/* 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 - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
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;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Post highlight */
.post-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.post-highlight .post-title {
font-size: 20px;
font-weight: 600;
color: #dc3545;
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);
}
/* 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;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 16px;
margin-top: 8px;
font-weight: 600;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 24px;
}
.content h2 {
font-size: 22px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
font-size: 20px;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Post highlight */
.post-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
text-align: center;
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.post-highlight .post-title {
font-size: 20px;
font-weight: 600;
color: #dc3545;
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);
}
/* 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;
}
/* 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;
}
.post-highlight .post-title {
font-size: 18px;
}
font-size: 18px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">⚠️ Important: Forum Post Removal Notice</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">⚠️ Important: Forum Post Removal Notice</div>
</div>
<div class="content">
<p>Hi {{postAuthorName}},</p>
<h1>Your Forum Post Has Been Removed</h1>
<p>
We're writing to inform you that your forum post has been removed from
Village Share by {{adminName}}.
</p>
<div class="post-highlight">
<div class="post-title">{{postTitle}}</div>
</div>
<div class="content">
<p>Hi {{postAuthorName}},</p>
<h1>Your Forum Post Has Been Removed</h1>
<p>We're writing to inform you that your forum post has been removed from RentAll by {{adminName}}.</p>
<div class="post-highlight">
<div class="post-title">{{postTitle}}</div>
</div>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</p>
</div>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;">
<li>Your post is no longer visible to other community members</li>
<li>All comments on this post are also hidden</li>
<li>The post cannot receive new comments or activity</li>
<li>You may still see it in your dashboard if viewing as an admin</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>If you believe this removal was made in error or if you have questions about our community guidelines, please don't hesitate to contact our support team:</p>
<p style="text-align: center;">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Community Guidelines:</strong></p>
<p>To prevent future removals, please familiarize yourself with our community guidelines and forum standards. Our team is happy to help you understand how to contribute positively to the RentAll community.</p>
</div>
<p>You can continue participating in the forum by visiting our <a href="{{forumUrl}}" style="color: #667eea;">community forum</a>.</p>
<p>Thank you for your understanding.</p>
<p><strong>Best regards,</strong><br>
The RentAll Team</p>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</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 your forum post was removed by our moderation team.</p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>Your post is no longer visible to other community members</li>
<li>All comments on this post are also hidden</li>
<li>The post cannot receive new comments or activity</li>
<li>
You may still see it in your dashboard if viewing as an admin
</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>
If you believe this removal was made in error or if you have questions
about our community guidelines, please don't hesitate to contact our
support team:
</p>
<p style="text-align: center">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Community Guidelines:</strong></p>
<p>
To prevent future removals, please familiarize yourself with our
community guidelines and forum standards. Our team is happy to help
you understand how to contribute positively to the Village Share
community.
</p>
</div>
<p>
You can continue participating in the forum by visiting our
<a href="{{forumUrl}}" style="color: #667eea">community forum</a>.
</p>
<p>Thank you for your understanding.</p>
<p>
<strong>Best regards,</strong><br />
The Village Share Team
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p>
<p>
This email was sent because your forum post was removed by our
moderation team.
</p>
<p>
If you have questions, please contact
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,303 +1,321 @@
<!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">
<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>New Reply to Your Comment</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;
}
/* 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;
}
/* 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 */
/* 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;
}
/* 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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment boxes */
.your-comment-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 15px 20px;
margin: 20px 0 10px 0;
border-radius: 6px;
}
.your-comment-box .label {
font-size: 12px;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 8px 0;
}
.your-comment-box .content-text {
color: #6c757d;
font-size: 14px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.reply-box {
background-color: #ffffff;
border: 2px solid #28a745;
padding: 20px;
margin: 0 0 20px 30px;
border-radius: 6px;
}
.reply-box .author {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 10px 0;
}
.reply-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.reply-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment boxes */
.your-comment-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 15px 20px;
margin: 20px 0 10px 0;
border-radius: 6px;
}
.your-comment-box .label {
font-size: 12px;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 8px 0;
}
.your-comment-box .content-text {
color: #6c757d;
font-size: 14px;
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
display: block;
width: 100%;
box-sizing: border-box;
}
.reply-box {
background-color: #ffffff;
border: 2px solid #28a745;
padding: 20px;
margin: 0 0 20px 30px;
border-radius: 6px;
}
.reply-box .author {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 10px 0;
}
.reply-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.reply-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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;
}
.reply-box {
margin: 0 0 20px 15px;
padding: 15px;
}
margin: 0 0 20px 15px;
padding: 15px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Forum Activity</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Forum Activity</div>
</div>
<div class="content">
<p>Hi {{commentAuthorName}},</p>
<h1>{{replierName}} replied to your comment</h1>
<p>Someone just replied to your comment in the forum:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="content">
<p>Hi {{commentAuthorName}},</p>
<h1>{{replierName}} replied to your comment</h1>
<p>Someone just replied to your comment in the forum:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="your-comment-box">
<div class="label">Your Comment</div>
<div class="content-text">{{parentCommentContent}}</div>
</div>
<div class="reply-box">
<div class="author">{{replierName}}</div>
<div class="content-text">{{replyContent}}</div>
<div class="timestamp">Posted {{timestamp}}</div>
</div>
<a href="{{postUrl}}" class="button">View Reply & Respond</a>
<p>Click the button above to see the full discussion and continue the conversation.</p>
<div class="info-box">
<p><strong>Tip:</strong> Thoughtful replies help create meaningful discussions and build community connections.</p>
</div>
<div class="your-comment-box">
<div class="label">Your Comment</div>
<div class="content-text">{{parentCommentContent}}</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because someone replied to your forum comment.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="reply-box">
<div class="author">{{replierName}}</div>
<div class="content-text">{{replyContent}}</div>
<div class="timestamp">Posted {{timestamp}}</div>
</div>
<a href="{{postUrl}}" class="button">View Reply & Respond</a>
<p>
Click the button above to see the full discussion and continue the
conversation.
</p>
<div class="info-box">
<p>
<strong>Tip:</strong> Thoughtful replies help create meaningful
discussions and build community connections.
</p>
</div>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
You received this email because someone replied to your forum comment.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,281 +1,303 @@
<!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">
<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>New Activity on a Forum Post You Follow</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;
}
/* 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;
}
/* 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 */
/* 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;
}
/* 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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .label {
font-size: 12px;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 8px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment box */
.comment-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.comment-box .author {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 10px 0;
}
.comment-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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;
display: block;
width: 100%;
box-sizing: border-box;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #28a745;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .label {
font-size: 12px;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 8px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Comment box */
.comment-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.comment-box .author {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 10px 0;
}
.comment-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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;
}
.comment-box {
padding: 15px;
}
padding: 15px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Forum Activity</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Forum Activity</div>
</div>
<div class="content">
<p>Hi {{participantName}},</p>
<h1>New activity on a post you're following</h1>
<p>
{{commenterName}} just commented on a forum post you've participated
in:
</p>
<div class="post-title-box">
<div class="label">Post You're Following</div>
<div class="title">{{postTitle}}</div>
</div>
<div class="content">
<p>Hi {{participantName}},</p>
<h1>New activity on a post you're following</h1>
<p>{{commenterName}} just commented on a forum post you've participated in:</p>
<div class="post-title-box">
<div class="label">Post You're Following</div>
<div class="title">{{postTitle}}</div>
</div>
<div class="comment-box">
<div class="author">{{commenterName}}</div>
<div class="content-text">{{commentContent}}</div>
<div class="timestamp">Posted {{timestamp}}</div>
</div>
<a href="{{postUrl}}" class="button">View Discussion</a>
<p>Click the button above to see the full conversation and join the discussion.</p>
<div class="info-box">
<p><strong>Stay engaged:</strong> You're receiving this because you've commented on this post. Keep the conversation going!</p>
</div>
<div class="comment-box">
<div class="author">{{commenterName}}</div>
<div class="content-text">{{commentContent}}</div>
<div class="timestamp">Posted {{timestamp}}</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because there's new activity on a forum post you've commented on.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<a href="{{postUrl}}" class="button">View Discussion</a>
<p>
Click the button above to see the full conversation and join the
discussion.
</p>
<div class="info-box">
<p>
<strong>Stay engaged:</strong> You're receiving this because you've
commented on this post. Keep the conversation going!
</p>
</div>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
You received this email because there's new activity on a forum post
you've commented on.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,305 +1,336 @@
<!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">
<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 Listing Has Been Removed</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;
}
/* 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;
}
/* 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 */
/* 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 - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
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;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* 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: #dc3545;
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);
}
/* 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;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header - Warning red gradient */
.header {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #f8d7da;
font-size: 16px;
margin-top: 8px;
font-weight: 600;
}
/* Content */
.content {
padding: 40px 30px;
font-size: 28px;
}
.content h1 {
font-size: 28px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 24px;
}
.content h2 {
font-size: 22px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
font-size: 20px;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0 0 10px 0;
color: #856404;
}
.warning-box p:last-child {
margin-bottom: 0;
}
/* Alert box */
.alert-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.alert-box p {
margin: 0 0 10px 0;
color: #721c24;
}
.alert-box p:last-child {
margin-bottom: 0;
}
/* Item highlight */
.item-highlight {
background-color: #f8f9fa;
border-radius: 6px;
padding: 20px;
margin: 20px 0;
text-align: center;
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.item-highlight .item-name {
font-size: 20px;
font-weight: 600;
color: #dc3545;
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);
}
/* 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;
}
/* 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;
}
font-size: 18px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">⚠️ Important: Listing Removal Notice</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">⚠️ Important: Listing Removal Notice</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>Your Listing Has Been Removed</h1>
<p>
We're writing to inform you that your listing has been removed from
Village Share by our moderation team.
</p>
<div class="item-highlight">
<div class="item-name">{{itemName}}</div>
</div>
<div class="content">
<p>Hi {{ownerName}},</p>
<h1>Your Listing Has Been Removed</h1>
<p>We're writing to inform you that your listing has been removed from RentAll by our moderation team.</p>
<div class="item-highlight">
<div class="item-name">{{itemName}}</div>
</div>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</p>
</div>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085;">
<li>Your listing is no longer visible to renters</li>
<li>You can still view it in your dashboard</li>
<li>No new rentals can be requested</li>
<li>Existing active rentals are not affected</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>If you believe this removal was made in error or if you have questions about our policies, please don't hesitate to contact our support team:</p>
<p style="text-align: center;">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Policies:</strong></p>
<p>To prevent future removals, please familiarize yourself with our community guidelines and listing standards. Our team is happy to help you understand what makes a great RentAll listing.</p>
</div>
<p>You can view your listings anytime from your <a href="{{dashboardUrl}}" style="color: #667eea;">dashboard</a>.</p>
<p>Thank you for your understanding.</p>
<p><strong>Best regards,</strong><br>
The RentAll Team</p>
<div class="alert-box">
<p><strong>Reason for Removal:</strong></p>
<p>{{deletionReason}}</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 your listing was removed by our moderation team.</p>
<p>If you have questions, please contact <a href="mailto:{{supportEmail}}">{{supportEmail}}</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="info-box">
<p><strong>What this means:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px; color: #004085">
<li>Your listing is no longer visible to renters</li>
<li>You can still view it in your dashboard</li>
<li>No new rentals can be requested</li>
<li>Existing active rentals are not affected</li>
</ul>
</div>
<h2>Need Help or Have Questions?</h2>
<p>
If you believe this removal was made in error or if you have questions
about our policies, please don't hesitate to contact our support team:
</p>
<p style="text-align: center">
<a href="mailto:{{supportEmail}}" class="button">Contact Support</a>
</p>
<div class="warning-box">
<p><strong>Review Our Policies:</strong></p>
<p>
To prevent future removals, please familiarize yourself with our
community guidelines and listing standards. Our team is happy to
help you understand what makes a great Village Share listing.
</p>
</div>
<p>
You can view your listings anytime from your
<a href="{{dashboardUrl}}" style="color: #667eea">dashboard</a>.
</p>
<p>Thank you for your understanding.</p>
<p>
<strong>Best regards,</strong><br />
The Village Share Team
</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>Building a community of sharing and trust</p>
<p>
This email was sent because your listing was removed by our moderation
team.
</p>
<p>
If you have questions, please contact
<a href="mailto:{{supportEmail}}">{{supportEmail}}</a>
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,243 +1,262 @@
<!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">
<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>New Message from {{senderName}}</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;
}
/* 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;
}
/* 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 */
/* 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;
}
/* 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);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Message box */
.message-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.message-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.message-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
font-size: 22px;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #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;
display: block;
width: 100%;
box-sizing: border-box;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
}
/* Message box */
.message-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.message-box .content-text {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.message-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* 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;
}
.message-box {
padding: 15px;
}
padding: 15px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">New Message</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">New Message</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>You have a new message from {{senderName}}</h1>
<p>{{senderName}} sent you a message on Village Share.</p>
<div class="message-box">
<div class="content-text">{{messageContent}}</div>
<div class="timestamp">Sent {{timestamp}}</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<a href="{{conversationUrl}}" class="button">View Conversation</a>
<h1>You have a new message from {{senderName}}</h1>
<p>
Click the button above to read and reply to this message on Village
Share.
</p>
<p>{{senderName}} sent you a message on RentAll.</p>
<div class="message-box">
<div class="content-text">{{messageContent}}</div>
<div class="timestamp">Sent {{timestamp}}</div>
</div>
<a href="{{conversationUrl}}" class="button">View Conversation</a>
<p>Click the button above to read and reply to this message on RentAll.</p>
<div class="info-box">
<p><strong>Tip:</strong> Reply quickly to keep your conversations active and build trust within the RentAll community.</p>
</div>
<div class="info-box">
<p>
<strong>Tip:</strong> Reply quickly to keep your conversations
active and build trust within the Village Share community.
</p>
</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because you have an account on RentAll and someone sent you a message.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
You received this email because you have an account on Village Share
and someone sent you a message.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -1,246 +1,276 @@
<!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>Password Changed Successfully - RentAll</title>
<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>Password Changed Successfully - Village Share</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;
}
/* 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;
}
/* 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 */
/* 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 p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* 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 {
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);
margin: 0;
border-radius: 0;
}
/* Header */
.header {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
padding: 40px 30px;
text-align: center;
.header,
.content,
.footer {
padding: 20px;
}
.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;
font-size: 28px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Success box */
.success-box {
background-color: #d4edda;
border-left: 4px solid #28a745;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.success-box p {
margin: 0;
color: #155724;
font-size: 14px;
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #0066cc;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #004085;
font-size: 14px;
}
/* Security box */
.security-box {
background-color: #f8d7da;
border-left: 4px solid #dc3545;
padding: 15px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.security-box p {
margin: 0;
color: #721c24;
font-size: 14px;
}
/* Details table */
.details-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
.details-table td {
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.details-table td:first-child {
font-weight: 600;
color: #495057;
width: 40%;
}
.details-table td:last-child {
color: #6c757d;
}
/* 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;
}
font-size: 22px;
}
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Password Changed Successfully</div>
<div class="header">
<div class="logo">Village Share</div>
<div class="tagline">Password Changed Successfully</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Your Password Has Been Changed</h1>
<div class="success-box">
<p>
<strong>Your password was successfully changed.</strong> You can now
use your new password to log in to your Village Share account.
</p>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<p>
This is a confirmation that the password for your Village Share
account has been changed. For your security, all existing sessions
have been logged out.
</p>
<h1>Your Password Has Been Changed</h1>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<div class="success-box">
<p><strong>Your password was successfully changed.</strong> You can now use your new password to log in to your RentAll account.</p>
</div>
<p>This is a confirmation that the password for your RentAll account has been changed. For your security, all existing sessions have been logged out.</p>
<table class="details-table">
<tr>
<td>Date & Time:</td>
<td>{{timestamp}}</td>
</tr>
<tr>
<td>Account Email:</td>
<td>{{email}}</td>
</tr>
</table>
<div class="security-box">
<p><strong>Didn't change your password?</strong> If you did not make this change, your account may be compromised. Please contact our support team immediately at support@rentall.com to secure your account.</p>
</div>
<div class="info-box">
<p><strong>Security reminder:</strong> Keep your password secure and never share it with anyone. We recommend using a strong, unique password and enabling two-factor authentication when available.</p>
</div>
<p>Thanks for using RentAll!</p>
<div class="security-box">
<p>
<strong>Didn't change your password?</strong> If you did not make
this change, your account may be compromised. Please contact our
support team immediately at support@villageshare.app to secure your
account.
</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>This is a security notification sent to confirm your password change. If you have any concerns about your account security, please contact our support team immediately.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<div class="info-box">
<p>
<strong>Security reminder:</strong> Keep your password secure and
never share it with anyone. We recommend using a strong, unique
password and enabling two-factor authentication when available.
</p>
</div>
<p>Thanks for using Village Share!</p>
</div>
<div class="footer">
<p><strong>Village Share</strong></p>
<p>
This is a security notification sent to confirm your password change.
If you have any concerns about your account security, please contact
our support team immediately.
</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<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>Reset Your Password - RentAll</title>
<title>Reset Your Password - Village Share</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
@@ -202,7 +202,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Password Reset Request</div>
</div>
@@ -211,7 +211,7 @@
<h1>Reset Your Password</h1>
<p>We received a request to reset the password for your RentAll account. Click the button below to choose a new password.</p>
<p>We received a request to reset the password for your Village Share account. Click the button below to choose a new password.</p>
<div style="text-align: center;">
<a href="{{resetUrl}}" class="button">Reset Password</a>
@@ -232,14 +232,14 @@
<p><strong>Security tip:</strong> Choose a strong password that includes a mix of uppercase and lowercase letters, numbers, and special characters. Never share your password with anyone.</p>
</div>
<p>Thanks for using RentAll!</p>
<p>Thanks for using Village Share!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>This is a transactional email sent in response to a password reset request. You received this message because someone requested a password reset for this email address.</p>
<p>If you have any questions or concerns, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Earnings Received - RentAll</title>
<title>Earnings Received - Village Share</title>
<style>
/* Reset styles */
body,
@@ -332,7 +332,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Earnings Received</div>
</div>
@@ -400,19 +400,19 @@
</div>
<p>
Thank you for being a valued member of the RentAll community! Keep
Thank you for being a valued member of the Village Share community! Keep
sharing your items to earn more.
</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
This is a notification about your earnings. You received this message
because a payout was successfully processed for your rental.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<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>Personal Information Updated - RentAll</title>
<title>Personal Information Updated - Village Share</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
@@ -199,7 +199,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Personal Information Updated</div>
</div>
@@ -209,7 +209,7 @@
<h1>Your Personal Information Has Been Updated</h1>
<div class="info-box">
<p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your RentAll account.</p>
<p><strong>Your account information was recently updated.</strong> This email is to notify you that changes were made to your personal information on your Village Share account.</p>
</div>
<p>We're sending you this notification as part of our commitment to keeping your account secure. If you made these changes, no further action is required.</p>
@@ -226,20 +226,20 @@
</table>
<div class="security-box">
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@rentall.com and consider changing your password.</p>
<p><strong>Didn't make these changes?</strong> If you did not update your personal information, your account may be compromised. Please contact our support team immediately at support@villageshare.app and consider changing your password.</p>
</div>
<div class="info-box">
<p><strong>Security tip:</strong> Regularly review your account information to ensure it's accurate and up to date. If you notice any suspicious activity, contact our support team right away.</p>
</div>
<p>Thanks for using RentAll!</p>
<p>Thanks for using Village Share!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>This is a security notification sent to confirm changes to your account. If you have any concerns about your account security, please contact our support team immediately.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -286,7 +286,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Request Approved</div>
</div>
@@ -343,14 +343,14 @@
<a href="{{rentalDetailsUrl}}" class="button">View Rental Details</a>
</p>
<p>Thank you for being part of the RentAll community!</p>
<p>Thank you for being part of the Village Share community!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</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>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Cancellation Confirmed - RentAll</title>
<title>Cancellation Confirmed - Village Share</title>
<style>
/* Reset styles */
body,
@@ -251,7 +251,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Cancellation Confirmation</div>
</div>
@@ -298,13 +298,13 @@
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
This is a confirmation of your rental cancellation. You received this
message because you cancelled a rental on RentAll.
message because you cancelled a rental on Village Share.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Cancelled - RentAll</title>
<title>Rental Cancelled - Village Share</title>
<style>
/* Reset styles */
body,
@@ -257,7 +257,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Update</div>
</div>
@@ -297,13 +297,13 @@
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
This is a notification about a rental cancellation. You received this
message because you were involved in a rental on RentAll.
message because you were involved in a rental on Village Share.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -303,7 +303,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Complete</div>
</div>
@@ -358,14 +358,14 @@
<a href="{{owningUrl}}" class="button">View My Listings</a>
</p>
<p>Thank you for being an excellent host on RentAll!</p>
<p>Thank you for being an excellent host on Village Share!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</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>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -256,7 +256,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Complete</div>
</div>
@@ -268,7 +268,7 @@
<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>
<p>On-time returns like yours help build trust in the Village Share community. Thank you!</p>
</div>
<h2>Rental Summary</h2>
@@ -300,14 +300,14 @@
<a href="{{browseItemsUrl}}" class="button">Browse Available Items</a>
</p>
<p>Thank you for being a valued member of the RentAll community!</p>
<p>Thank you for being a valued member of the Village Share community!</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</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>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -220,7 +220,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Confirmed</div>
</div>
@@ -269,14 +269,14 @@
<li>Contact the owner if you have any questions</li>
</ul>
<p>Thank you for choosing RentAll! We hope you have a great rental experience.</p>
<p>Thank you for choosing Village Share! We hope you have a great rental experience.</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>This is a transactional email confirming your rental. You received this message because you have an active rental transaction on our platform.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Request Declined - RentAll</title>
<title>Rental Request Declined - Village Share</title>
<style>
/* Reset styles */
body,
@@ -247,7 +247,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Request Update</div>
</div>
@@ -289,7 +289,7 @@
</p>
<p>
We encourage you to explore other similar items available for rent
on RentAll. There are many great options waiting for you!
on Village Share. There are many great options waiting for you!
</p>
</div>
@@ -304,13 +304,13 @@
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
This is a notification about your rental request. You received this
message because you submitted a rental request on RentAll.
message because you submitted a rental request on Village Share.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Rental Request Submitted - RentAll</title>
<title>Rental Request Submitted - Village Share</title>
<style>
/* Reset styles */
body,
@@ -245,7 +245,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Request Submitted</div>
</div>
@@ -307,13 +307,13 @@
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
This is a confirmation email for your rental request. You received
this message because you submitted a rental request on RentAll.
this message because you submitted a rental request on Village Share.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -4,7 +4,7 @@
<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 - RentAll</title>
<title>Rental Request - Village Share</title>
<style>
/* Reset styles */
body,
@@ -245,7 +245,7 @@
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="logo">Village Share</div>
<div class="tagline">Rental Request</div>
</div>
@@ -319,14 +319,14 @@
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p><strong>Village Share</strong></p>
<p>
This is a transactional email about a rental request for your listing.
You received this message because you have an active listing on
RentAll.
Village Share.
</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
<p>&copy; 2025 Village Share. All rights reserved.</p>
</div>
</div>
</body>

View File

@@ -0,0 +1,13 @@
// Integration test setup
// Integration tests use a real database, so we don't mock DATABASE_URL
process.env.NODE_ENV = 'test';
// Ensure JWT secrets are set for integration tests
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret';
// Set other required env vars if not already set
process.env.GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY || 'test-key';
process.env.STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || 'sk_test_key';

View File

@@ -20,6 +20,7 @@ jest.mock('../../middleware/rateLimiter', () => ({
passwordResetRequestLimiter: (req, res, next) => next(),
verifyEmailLimiter: (req, res, next) => next(),
resendVerificationLimiter: (req, res, next) => next(),
emailVerificationLimiter: (req, res, next) => next(),
}));
// Mock CSRF protection for tests
@@ -225,7 +226,7 @@ describe('Auth Integration Tests', () => {
})
.expect(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
});
it('should reject login with non-existent email', async () => {
@@ -237,7 +238,7 @@ describe('Auth Integration Tests', () => {
})
.expect(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
});
it('should increment login attempts on failed login', async () => {
@@ -421,7 +422,8 @@ describe('Auth Integration Tests', () => {
describe('POST /auth/verify-email', () => {
let testUser;
let verificationToken;
let verificationCode;
let accessToken;
beforeEach(async () => {
testUser = await createTestUser({
@@ -430,13 +432,21 @@ describe('Auth Integration Tests', () => {
});
await testUser.generateVerificationToken();
await testUser.reload();
verificationToken = testUser.verificationToken;
verificationCode = testUser.verificationToken; // Now a 6-digit code
// Generate access token for authentication
accessToken = jwt.sign(
{ id: testUser.id, email: testUser.email, jwtVersion: testUser.jwtVersion || 0 },
process.env.JWT_ACCESS_SECRET || 'test-access-secret',
{ expiresIn: '15m' }
);
});
it('should verify email with valid token', async () => {
it('should verify email with valid code', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: verificationToken })
.set('Cookie', `accessToken=${accessToken}`)
.send({ code: verificationCode })
.expect(200);
expect(response.body.message).toBe('Email verified successfully');
@@ -448,13 +458,14 @@ describe('Auth Integration Tests', () => {
expect(testUser.verificationToken).toBeNull();
});
it('should reject verification with invalid token', async () => {
it('should reject verification with invalid code', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'invalid-token' })
.set('Cookie', `accessToken=${accessToken}`)
.send({ code: '000000' })
.expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
expect(response.body.code).toBe('VERIFICATION_INVALID');
});
it('should reject verification for already verified user', async () => {
@@ -463,10 +474,11 @@ describe('Auth Integration Tests', () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ token: verificationToken })
.set('Cookie', `accessToken=${accessToken}`)
.send({ code: verificationCode })
.expect(400);
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
expect(response.body.code).toBe('ALREADY_VERIFIED');
});
});

View File

@@ -40,6 +40,7 @@ describe('User Model - Email Verification', () => {
lastName: 'User',
verificationToken: null,
verificationTokenExpiry: null,
verificationAttempts: 0,
isVerified: false,
verifiedAt: null,
update: jest.fn().mockImplementation(function(updates) {
@@ -53,18 +54,17 @@ describe('User Model - Email Verification', () => {
});
describe('generateVerificationToken', () => {
it('should generate a random token and set 24-hour expiry', async () => {
const mockRandomBytes = Buffer.from('a'.repeat(32));
const mockToken = mockRandomBytes.toString('hex'); // This will be "61" repeated 32 times
crypto.randomBytes.mockReturnValue(mockRandomBytes);
it('should generate a 6-digit code and set 24-hour expiry', async () => {
const mockCode = 123456;
crypto.randomInt.mockReturnValue(mockCode);
await User.prototype.generateVerificationToken.call(mockUser);
expect(crypto.randomBytes).toHaveBeenCalledWith(32);
expect(crypto.randomInt).toHaveBeenCalledWith(100000, 999999);
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({
verificationToken: mockToken
verificationToken: '123456',
verificationAttempts: 0,
})
);
@@ -77,40 +77,40 @@ describe('User Model - Email Verification', () => {
expect(expiryTime).toBeLessThan(expectedExpiry + 1000);
});
it('should update the user with token and expiry', async () => {
const mockRandomBytes = Buffer.from('b'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
it('should update the user with code and expiry', async () => {
const mockCode = 654321;
crypto.randomInt.mockReturnValue(mockCode);
const result = await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.update).toHaveBeenCalledTimes(1);
expect(result.verificationToken).toBe(mockToken);
expect(result.verificationToken).toBe('654321');
expect(result.verificationTokenExpiry).toBeInstanceOf(Date);
});
it('should generate unique tokens on multiple calls', async () => {
const mockRandomBytes1 = Buffer.from('a'.repeat(32));
const mockRandomBytes2 = Buffer.from('b'.repeat(32));
crypto.randomBytes
.mockReturnValueOnce(mockRandomBytes1)
.mockReturnValueOnce(mockRandomBytes2);
it('should generate unique codes on multiple calls', async () => {
crypto.randomInt
.mockReturnValueOnce(111111)
.mockReturnValueOnce(222222);
await User.prototype.generateVerificationToken.call(mockUser);
const firstToken = mockUser.update.mock.calls[0][0].verificationToken;
const firstCode = mockUser.update.mock.calls[0][0].verificationToken;
await User.prototype.generateVerificationToken.call(mockUser);
const secondToken = mockUser.update.mock.calls[1][0].verificationToken;
const secondCode = mockUser.update.mock.calls[1][0].verificationToken;
expect(firstToken).not.toBe(secondToken);
expect(firstCode).not.toBe(secondCode);
});
});
describe('isVerificationTokenValid', () => {
beforeEach(() => {
// Mock timingSafeEqual to do a simple comparison
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
});
it('should return true for valid token and non-expired time', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
mockUser.verificationToken = validToken;
@@ -131,25 +131,25 @@ describe('User Model - Email Verification', () => {
});
it('should return false for missing expiry', () => {
mockUser.verificationToken = 'valid-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = null;
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'valid-token');
const result = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
expect(result).toBe(false);
});
it('should return false for mismatched token', () => {
mockUser.verificationToken = 'correct-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(Date.now() + 60 * 60 * 1000);
const result = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
const result = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
expect(result).toBe(false);
});
it('should return false for expired token', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const pastExpiry = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago
mockUser.verificationToken = validToken;
@@ -161,7 +161,7 @@ describe('User Model - Email Verification', () => {
});
it('should return false for token expiring in the past by 1 second', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const pastExpiry = new Date(Date.now() - 1000); // 1 second ago
mockUser.verificationToken = validToken;
@@ -173,7 +173,7 @@ describe('User Model - Email Verification', () => {
});
it('should handle edge case of token expiring exactly now', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
// Set expiry 1ms in the future to handle timing precision
const nowExpiry = new Date(Date.now() + 1);
@@ -187,7 +187,7 @@ describe('User Model - Email Verification', () => {
});
it('should handle string dates correctly', () => {
const validToken = 'valid-token-123';
const validToken = '123456';
const futureExpiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // String date
mockUser.verificationToken = validToken;
@@ -201,7 +201,7 @@ describe('User Model - Email Verification', () => {
describe('verifyEmail', () => {
it('should mark user as verified and clear token fields', async () => {
mockUser.verificationToken = 'some-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date();
await User.prototype.verifyEmail.call(mockUser);
@@ -245,19 +245,22 @@ describe('User Model - Email Verification', () => {
});
describe('Complete verification flow', () => {
beforeEach(() => {
crypto.timingSafeEqual = jest.fn((a, b) => a.equals(b));
});
it('should complete full verification flow successfully', async () => {
// Step 1: Generate verification token
const mockRandomBytes = Buffer.from('c'.repeat(32));
const mockToken = mockRandomBytes.toString('hex');
crypto.randomBytes.mockReturnValue(mockRandomBytes);
// Step 1: Generate verification code
const mockCode = 999888;
crypto.randomInt.mockReturnValue(mockCode);
await User.prototype.generateVerificationToken.call(mockUser);
expect(mockUser.verificationToken).toBe(mockToken);
expect(mockUser.verificationToken).toBe('999888');
expect(mockUser.verificationTokenExpiry).toBeInstanceOf(Date);
// Step 2: Validate token
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, mockToken);
// Step 2: Validate code
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '999888');
expect(isValid).toBe(true);
// Step 3: Verify email
@@ -270,25 +273,23 @@ describe('User Model - Email Verification', () => {
});
it('should fail verification with wrong token', async () => {
// Generate token
const mockToken = 'd'.repeat(64);
const mockRandomBytes = Buffer.from('d'.repeat(32));
crypto.randomBytes.mockReturnValue(mockRandomBytes);
// Generate code
crypto.randomInt.mockReturnValue(123456);
await User.prototype.generateVerificationToken.call(mockUser);
// Try to validate with wrong token
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'wrong-token');
// Try to validate with wrong code
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '654321');
expect(isValid).toBe(false);
});
it('should fail verification with expired token', async () => {
// Manually set an expired token
mockUser.verificationToken = 'expired-token';
mockUser.verificationToken = '123456';
mockUser.verificationTokenExpiry = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, 'expired-token');
const isValid = User.prototype.isVerificationTokenValid.call(mockUser, '123456');
expect(isValid).toBe(false);
});

View File

@@ -45,10 +45,15 @@ jest.mock('../../../middleware/rateLimiter', () => ({
loginLimiter: (req, res, next) => next(),
registerLimiter: (req, res, next) => next(),
passwordResetLimiter: (req, res, next) => next(),
emailVerificationLimiter: (req, res, next) => next(),
}));
jest.mock('../../../middleware/auth', () => ({
optionalAuth: (req, res, next) => next(),
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
}));
jest.mock('../../../services/email', () => ({
@@ -290,7 +295,7 @@ describe('Auth Routes', () => {
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
});
it('should reject login with invalid password', async () => {
@@ -311,7 +316,7 @@ describe('Auth Routes', () => {
});
expect(response.status).toBe(401);
expect(response.body.error).toBe('Invalid credentials');
expect(response.body.error).toBe('Unable to log in. Please check your email and password, or create an account.');
expect(mockUser.incLoginAttempts).toHaveBeenCalled();
});
@@ -536,95 +541,147 @@ describe('Auth Routes', () => {
});
describe('POST /auth/verify-email', () => {
it('should verify email with valid token', async () => {
it('should verify email with valid 6-digit code', async () => {
const mockUser = {
id: 1,
id: 'user-123',
email: 'test@example.com',
isVerified: false,
verificationToken: 'valid-token',
verificationToken: '123456',
verificationTokenExpiry: new Date(Date.now() + 3600000), // 1 hour from now
verificationAttempts: 0,
isVerificationLocked: jest.fn().mockReturnValue(false),
isVerificationTokenValid: jest.fn().mockReturnValue(true),
verifyEmail: jest.fn().mockResolvedValue()
};
User.findOne.mockResolvedValue(mockUser);
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'valid-token' });
.send({ code: '123456' });
expect(response.status).toBe(200);
expect(response.body.message).toBe('Email verified successfully');
expect(response.body.user).toMatchObject({
id: 1,
id: 'user-123',
email: 'test@example.com',
isVerified: true
});
expect(mockUser.verifyEmail).toHaveBeenCalled();
});
it('should reject missing token', async () => {
it('should reject missing code', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toBe('Verification token required');
expect(response.body.code).toBe('TOKEN_REQUIRED');
expect(response.body.error).toBe('Verification code required');
expect(response.body.code).toBe('CODE_REQUIRED');
});
it('should reject invalid token', async () => {
User.findOne.mockResolvedValue(null);
it('should reject invalid code format (not 6 digits)', async () => {
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '12345' }); // Only 5 digits
expect(response.status).toBe(400);
expect(response.body.error).toBe('Verification code must be 6 digits');
expect(response.body.code).toBe('INVALID_CODE_FORMAT');
});
it('should reject when user not found', async () => {
User.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'invalid-token' });
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid verification token');
expect(response.body.code).toBe('VERIFICATION_TOKEN_INVALID');
expect(response.status).toBe(404);
expect(response.body.error).toBe('User not found');
expect(response.body.code).toBe('USER_NOT_FOUND');
});
it('should reject already verified user', async () => {
const mockUser = {
id: 1,
id: 'user-123',
isVerified: true
};
User.findOne.mockResolvedValue(mockUser);
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'some-token' });
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Email already verified');
expect(response.body.code).toBe('ALREADY_VERIFIED');
});
it('should reject expired token', async () => {
it('should reject when too many verification attempts', async () => {
const mockUser = {
id: 1,
id: 'user-123',
isVerified: false,
isVerificationTokenValid: jest.fn().mockReturnValue(false)
isVerificationLocked: jest.fn().mockReturnValue(true)
};
User.findOne.mockResolvedValue(mockUser);
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'expired-token' });
.send({ code: '123456' });
expect(response.status).toBe(429);
expect(response.body.error).toContain('Too many verification attempts');
expect(response.body.code).toBe('TOO_MANY_ATTEMPTS');
});
it('should reject when no verification code exists', async () => {
const mockUser = {
id: 'user-123',
isVerified: false,
verificationToken: null,
isVerificationLocked: jest.fn().mockReturnValue(false)
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('No verification code found');
expect(response.body.code).toBe('NO_CODE');
});
it('should reject expired verification code', async () => {
const mockUser = {
id: 'user-123',
isVerified: false,
verificationToken: '123456',
verificationTokenExpiry: new Date(Date.now() - 3600000), // 1 hour ago (expired)
isVerificationLocked: jest.fn().mockReturnValue(false)
};
User.findByPk.mockResolvedValue(mockUser);
const response = await request(app)
.post('/auth/verify-email')
.send({ code: '123456' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('expired');
expect(response.body.code).toBe('VERIFICATION_TOKEN_EXPIRED');
expect(response.body.code).toBe('VERIFICATION_EXPIRED');
});
it('should handle verification errors', async () => {
User.findOne.mockRejectedValue(new Error('Database error'));
User.findByPk.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.post('/auth/verify-email')
.send({ token: 'some-token' });
.send({ code: '123456' });
expect(response.status).toBe(500);
expect(response.body.error).toBe('Email verification failed. Please try again.');
@@ -835,6 +892,48 @@ describe('Auth Routes', () => {
});
});
describe('GET /auth/status', () => {
it('should return authenticated true when user is logged in', async () => {
// The optionalAuth middleware sets req.user if authenticated
// We need to modify the mock for this specific test
const mockUser = {
id: 1,
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
isVerified: true
};
// Create a custom app for this test with user set
const statusApp = express();
statusApp.use(express.json());
statusApp.use((req, res, next) => {
req.user = mockUser;
next();
});
statusApp.use('/auth', authRoutes);
const response = await request(statusApp)
.get('/auth/status');
expect(response.status).toBe(200);
expect(response.body.authenticated).toBe(true);
expect(response.body.user).toMatchObject({
id: 1,
email: 'test@example.com'
});
});
it('should return authenticated false when user is not logged in', async () => {
const response = await request(app)
.get('/auth/status');
expect(response.status).toBe(200);
expect(response.body.authenticated).toBe(false);
expect(response.body.user).toBeUndefined();
});
});
describe('POST /auth/forgot-password', () => {
it('should send password reset email for existing user', async () => {
const mockUser = {

View File

@@ -0,0 +1,328 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123' };
next();
},
}));
jest.mock('../../../services/conditionCheckService', () => ({
submitConditionCheck: jest.fn(),
getConditionChecks: jest.fn(),
getConditionCheckTimeline: jest.fn(),
getAvailableChecks: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
jest.mock('../../../utils/s3KeyValidator', () => ({
validateS3Keys: jest.fn().mockReturnValue({ valid: true }),
}));
jest.mock('../../../config/imageLimits', () => ({
IMAGE_LIMITS: { conditionChecks: 10 },
}));
const ConditionCheckService = require('../../../services/conditionCheckService');
const { validateS3Keys } = require('../../../utils/s3KeyValidator');
const conditionCheckRoutes = require('../../../routes/conditionChecks');
const app = express();
app.use(express.json());
app.use('/condition-checks', conditionCheckRoutes);
// Error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
describe('Condition Check Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /condition-checks/:rentalId', () => {
const validConditionCheck = {
checkType: 'pre_rental',
notes: 'Item in good condition',
imageFilenames: ['condition-checks/uuid1.jpg', 'condition-checks/uuid2.jpg'],
};
it('should submit a condition check successfully', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'pre_rental',
notes: 'Item in good condition',
imageFilenames: validConditionCheck.imageFilenames,
submittedBy: 'user-123',
createdAt: new Date().toISOString(),
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send(validConditionCheck);
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.conditionCheck).toMatchObject({
id: 'check-1',
checkType: 'pre_rental',
});
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'pre_rental',
'user-123',
validConditionCheck.imageFilenames,
'Item in good condition'
);
});
it('should handle empty image array', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'post_rental',
imageFilenames: [],
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'post_rental',
notes: 'No photos',
});
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'post_rental',
'user-123',
[],
'No photos'
);
});
it('should reject invalid S3 keys', async () => {
validateS3Keys.mockReturnValueOnce({
valid: false,
error: 'Invalid S3 key format',
invalidKeys: ['invalid-key'],
});
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'pre_rental',
imageFilenames: ['invalid-key'],
});
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Invalid S3 key format');
expect(response.body.details).toContain('invalid-key');
});
it('should handle service errors', async () => {
ConditionCheckService.submitConditionCheck.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.post('/condition-checks/rental-123')
.send(validConditionCheck);
expect(response.status).toBe(400);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Rental not found');
});
it('should handle non-array imageFilenames gracefully', async () => {
const mockConditionCheck = {
id: 'check-1',
rentalId: 'rental-123',
checkType: 'pre_rental',
imageFilenames: [],
};
ConditionCheckService.submitConditionCheck.mockResolvedValue(mockConditionCheck);
const response = await request(app)
.post('/condition-checks/rental-123')
.send({
checkType: 'pre_rental',
imageFilenames: 'not-an-array',
});
expect(response.status).toBe(201);
// Should convert to empty array
expect(ConditionCheckService.submitConditionCheck).toHaveBeenCalledWith(
'rental-123',
'pre_rental',
'user-123',
[],
undefined
);
});
});
describe('GET /condition-checks/:rentalId', () => {
it('should return condition checks for a rental', async () => {
const mockChecks = [
{
id: 'check-1',
checkType: 'pre_rental',
notes: 'Good condition',
createdAt: '2024-01-01T00:00:00Z',
},
{
id: 'check-2',
checkType: 'post_rental',
notes: 'Minor wear',
createdAt: '2024-01-15T00:00:00Z',
},
];
ConditionCheckService.getConditionChecks.mockResolvedValue(mockChecks);
const response = await request(app)
.get('/condition-checks/rental-123');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.conditionChecks).toHaveLength(2);
expect(response.body.conditionChecks[0].checkType).toBe('pre_rental');
expect(ConditionCheckService.getConditionChecks).toHaveBeenCalledWith('rental-123');
});
it('should return empty array when no checks exist', async () => {
ConditionCheckService.getConditionChecks.mockResolvedValue([]);
const response = await request(app)
.get('/condition-checks/rental-456');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.conditionChecks).toHaveLength(0);
});
it('should handle service errors', async () => {
ConditionCheckService.getConditionChecks.mockRejectedValue(
new Error('Database error')
);
const response = await request(app)
.get('/condition-checks/rental-123');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Failed to fetch condition checks');
});
});
describe('GET /condition-checks/:rentalId/timeline', () => {
it('should return condition check timeline', async () => {
const mockTimeline = {
rental: { id: 'rental-123', status: 'completed' },
checks: [
{ type: 'pre_rental', status: 'completed', completedAt: '2024-01-01' },
{ type: 'post_rental', status: 'pending', completedAt: null },
],
};
ConditionCheckService.getConditionCheckTimeline.mockResolvedValue(mockTimeline);
const response = await request(app)
.get('/condition-checks/rental-123/timeline');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.timeline).toMatchObject(mockTimeline);
expect(ConditionCheckService.getConditionCheckTimeline).toHaveBeenCalledWith('rental-123');
});
it('should handle service errors', async () => {
ConditionCheckService.getConditionCheckTimeline.mockRejectedValue(
new Error('Rental not found')
);
const response = await request(app)
.get('/condition-checks/rental-123/timeline');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Rental not found');
});
});
describe('GET /condition-checks', () => {
it('should return available checks for current user', async () => {
const mockAvailableChecks = [
{
rentalId: 'rental-1',
itemName: 'Camera',
checkType: 'pre_rental',
dueDate: '2024-01-10',
},
{
rentalId: 'rental-2',
itemName: 'Laptop',
checkType: 'post_rental',
dueDate: '2024-01-15',
},
];
ConditionCheckService.getAvailableChecks.mockResolvedValue(mockAvailableChecks);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.availableChecks).toHaveLength(2);
expect(response.body.availableChecks[0].itemName).toBe('Camera');
expect(ConditionCheckService.getAvailableChecks).toHaveBeenCalledWith('user-123');
});
it('should return empty array when no checks available', async () => {
ConditionCheckService.getAvailableChecks.mockResolvedValue([]);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.availableChecks).toHaveLength(0);
});
it('should handle service errors', async () => {
ConditionCheckService.getAvailableChecks.mockRejectedValue(
new Error('Database error')
);
const response = await request(app)
.get('/condition-checks');
expect(response.status).toBe(500);
expect(response.body.success).toBe(false);
expect(response.body.error).toBe('Failed to fetch available checks');
});
});
});

View File

@@ -0,0 +1,813 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies before requiring the route
jest.mock('../../../models', () => ({
ForumPost: {
findAndCountAll: jest.fn(),
findByPk: jest.fn(),
findOne: jest.fn(),
findAll: jest.fn(),
create: jest.fn(),
},
ForumComment: {
findAll: jest.fn(),
findByPk: jest.fn(),
create: jest.fn(),
count: jest.fn(),
destroy: jest.fn(),
},
PostTag: {
findAll: jest.fn(),
findOrCreate: jest.fn(),
create: jest.fn(),
destroy: jest.fn(),
},
User: {
findByPk: jest.fn(),
},
sequelize: {
transaction: jest.fn(() => ({
commit: jest.fn(),
rollback: jest.fn(),
})),
},
}));
jest.mock('sequelize', () => ({
Op: {
or: Symbol('or'),
iLike: Symbol('iLike'),
in: Symbol('in'),
ne: Symbol('ne'),
},
fn: jest.fn((name, col) => ({ fn: name, col })),
col: jest.fn((name) => ({ col: name })),
}));
jest.mock('../../../middleware/auth', () => ({
authenticateToken: (req, res, next) => {
req.user = { id: 'user-123', role: 'user', isVerified: true };
next();
},
requireAdmin: (req, res, next) => {
if (req.user && req.user.role === 'admin') {
next();
} else {
res.status(403).json({ error: 'Admin access required' });
}
},
optionalAuth: (req, res, next) => next(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
withRequestId: jest.fn(() => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
})),
}));
jest.mock('../../../services/email', () => ({
forum: {
sendNewPostNotification: jest.fn().mockResolvedValue(),
sendNewCommentNotification: jest.fn().mockResolvedValue(),
sendAnswerAcceptedNotification: jest.fn().mockResolvedValue(),
sendReplyNotification: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../services/googleMapsService', () => ({
geocodeAddress: jest.fn().mockResolvedValue({ lat: 40.7128, lng: -74.006 }),
}));
jest.mock('../../../services/locationService', () => ({
getOrCreateLocation: jest.fn().mockResolvedValue({ id: 'loc-123' }),
}));
jest.mock('../../../utils/s3KeyValidator', () => ({
validateS3Keys: jest.fn().mockReturnValue({ valid: true }),
}));
jest.mock('../../../config/imageLimits', () => ({
IMAGE_LIMITS: { forum: 10 },
}));
const { ForumPost, ForumComment, PostTag, User } = require('../../../models');
const forumRoutes = require('../../../routes/forum');
const app = express();
app.use(express.json());
app.use('/forum', forumRoutes);
// Error handler
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message });
});
describe('Forum Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /forum/posts', () => {
it('should return paginated posts', async () => {
const mockPosts = [
{
id: 'post-1',
title: 'Test Post',
content: 'Test content',
category: 'question',
status: 'open',
commentCount: 5,
viewCount: 100,
author: { id: 'user-1', firstName: 'John', lastName: 'Doe' },
tags: [{ id: 'tag-1', name: 'javascript' }],
toJSON: function() { return this; }
},
];
ForumPost.findAndCountAll.mockResolvedValue({
count: 1,
rows: mockPosts,
});
const response = await request(app)
.get('/forum/posts')
.query({ page: 1, limit: 20 });
expect(response.status).toBe(200);
expect(response.body.posts).toHaveLength(1);
expect(response.body.posts[0].title).toBe('Test Post');
expect(response.body.totalPages).toBe(1);
expect(response.body.currentPage).toBe(1);
expect(response.body.totalPosts).toBe(1);
});
it('should filter posts by category', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ category: 'question' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
category: 'question',
}),
})
);
});
it('should search posts by title and content', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ search: 'javascript' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalled();
});
it('should sort posts by different criteria', async () => {
ForumPost.findAndCountAll.mockResolvedValue({
count: 0,
rows: [],
});
const response = await request(app)
.get('/forum/posts')
.query({ sort: 'comments' });
expect(response.status).toBe(200);
expect(ForumPost.findAndCountAll).toHaveBeenCalledWith(
expect.objectContaining({
order: expect.arrayContaining([
['commentCount', 'DESC'],
]),
})
);
});
it('should handle database errors', async () => {
ForumPost.findAndCountAll.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/forum/posts');
expect(response.status).toBe(500);
});
});
describe('GET /forum/posts/:id', () => {
it('should return a single post with comments', async () => {
const mockPost = {
id: 'post-1',
title: 'Test Post',
content: 'Test content',
viewCount: 10,
isDeleted: false,
comments: [],
increment: jest.fn().mockResolvedValue(),
toJSON: function() {
const { increment, toJSON, ...rest } = this;
return rest;
},
author: { id: 'user-1', firstName: 'John', lastName: 'Doe', role: 'user' },
tags: [],
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.get('/forum/posts/post-1');
expect(response.status).toBe(200);
expect(response.body.title).toBe('Test Post');
expect(mockPost.increment).toHaveBeenCalledWith('viewCount', { silent: true });
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.get('/forum/posts/non-existent');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Post not found');
});
it('should return 404 for deleted post (non-admin)', async () => {
const mockPost = {
id: 'post-1',
isDeleted: true,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.get('/forum/posts/post-1');
expect(response.status).toBe(404);
expect(response.body.error).toBe('Post not found');
});
});
describe('POST /forum/posts', () => {
const validPostData = {
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
tags: ['javascript', 'react'],
};
it('should create a new post successfully', async () => {
const mockCreatedPost = {
id: 'new-post-id',
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
authorId: 'user-123',
status: 'open',
};
const mockPostWithDetails = {
...mockCreatedPost,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe' },
tags: [{ id: 'tag-1', tagName: 'javascript' }],
toJSON: function() { return this; },
};
ForumPost.create.mockResolvedValue(mockCreatedPost);
// After create, findByPk is called to get post with details
ForumPost.findByPk.mockResolvedValue(mockPostWithDetails);
const response = await request(app)
.post('/forum/posts')
.send(validPostData);
expect(response.status).toBe(201);
expect(ForumPost.create).toHaveBeenCalledWith(
expect.objectContaining({
title: 'New Forum Post',
content: 'This is the content of the post',
category: 'question',
authorId: 'user-123',
})
);
});
it('should handle Sequelize validation error for missing title', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ content: 'Content without title', category: 'question' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for missing content', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title without content', category: 'question' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for missing category', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title', content: 'Content' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for invalid category', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Title', content: 'Content', category: 'invalid' });
expect(response.status).toBe(500);
});
it('should handle Sequelize validation error for title too short', async () => {
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumPost.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts')
.send({ title: 'Hi', content: 'Content', category: 'question' });
expect(response.status).toBe(500);
});
});
describe('PUT /forum/posts/:id', () => {
it('should update own post successfully', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
title: 'Original Title',
content: 'Original content',
isDeleted: false,
setTags: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
PostTag.findOrCreate.mockResolvedValue([{ id: 'tag-1', name: 'updated' }]);
const response = await request(app)
.put('/forum/posts/post-1')
.send({ title: 'Updated Title', content: 'Updated content' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Updated Title',
content: 'Updated content',
})
);
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.put('/forum/posts/non-existent')
.send({ title: 'Updated' });
expect(response.status).toBe(404);
});
it('should return 403 when updating other users post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.put('/forum/posts/post-1')
.send({ title: 'Updated' });
expect(response.status).toBe(403);
expect(response.body.error).toBe('Unauthorized');
});
});
describe('DELETE /forum/posts/:id', () => {
it('should hard delete own post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
destroy: jest.fn().mockResolvedValue(),
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/posts/post-1');
expect(response.status).toBe(204);
expect(mockPost.destroy).toHaveBeenCalled();
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/posts/non-existent');
expect(response.status).toBe(404);
});
it('should return 403 when deleting other users post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/posts/post-1');
expect(response.status).toBe(403);
});
});
describe('POST /forum/posts/:id/comments', () => {
it('should add a comment to a post', async () => {
const mockPost = {
id: 'post-1',
authorId: 'post-author',
isDeleted: false,
status: 'open',
increment: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
};
const mockCreatedComment = {
id: 'comment-1',
content: 'Great post!',
authorId: 'user-123',
postId: 'post-1',
};
const mockCommentWithDetails = {
...mockCreatedComment,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
ForumComment.create.mockResolvedValue(mockCreatedComment);
// After create, findByPk is called to get comment with details
ForumComment.findByPk.mockResolvedValue(mockCommentWithDetails);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Great post!' });
expect(response.status).toBe(201);
expect(ForumComment.create).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Great post!',
authorId: 'user-123',
postId: 'post-1',
})
);
expect(mockPost.increment).toHaveBeenCalledWith('commentCount');
});
it('should handle Sequelize validation error for missing content', async () => {
const mockPost = {
id: 'post-1',
status: 'open',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const validationError = new Error('Validation error');
validationError.name = 'SequelizeValidationError';
ForumComment.create.mockRejectedValue(validationError);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({});
expect(response.status).toBe(500);
});
it('should return 404 for non-existent post', async () => {
ForumPost.findByPk.mockResolvedValue(null);
const response = await request(app)
.post('/forum/posts/non-existent/comments')
.send({ content: 'Comment' });
expect(response.status).toBe(404);
});
it('should return 403 when commenting on closed post', async () => {
const mockPost = {
id: 'post-1',
isDeleted: false,
status: 'closed',
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Comment' });
expect(response.status).toBe(403);
expect(response.body.error).toContain('closed');
});
it('should support replying to another comment', async () => {
const mockPost = {
id: 'post-1',
authorId: 'post-author',
isDeleted: false,
status: 'open',
increment: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
};
const mockParentComment = {
id: 'parent-comment',
postId: 'post-1',
authorId: 'other-user',
isDeleted: false,
};
const mockCreatedReply = {
id: 'reply-1',
content: 'Reply to comment',
parentCommentId: 'parent-comment',
authorId: 'user-123',
postId: 'post-1',
};
const mockReplyWithDetails = {
...mockCreatedReply,
author: { id: 'user-123', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
// First findByPk call checks parent comment, second gets created comment with details
ForumComment.findByPk
.mockResolvedValueOnce(mockParentComment)
.mockResolvedValueOnce(mockReplyWithDetails);
ForumComment.create.mockResolvedValue(mockCreatedReply);
const response = await request(app)
.post('/forum/posts/post-1/comments')
.send({ content: 'Reply to comment', parentCommentId: 'parent-comment' });
expect(response.status).toBe(201);
expect(ForumComment.create).toHaveBeenCalledWith(
expect.objectContaining({
parentCommentId: 'parent-comment',
})
);
});
});
describe('GET /forum/my-posts', () => {
it('should return authenticated users posts', async () => {
const mockPosts = [
{
id: 'post-1',
title: 'My Post',
authorId: 'user-123',
toJSON: function() { return this; },
},
];
ForumPost.findAll.mockResolvedValue(mockPosts);
const response = await request(app)
.get('/forum/my-posts');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(1);
expect(ForumPost.findAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
authorId: 'user-123',
}),
})
);
});
});
describe('GET /forum/tags', () => {
it('should return all tags', async () => {
const mockTags = [
{ tagName: 'javascript', count: 10 },
{ tagName: 'react', count: 5 },
];
PostTag.findAll.mockResolvedValue(mockTags);
const response = await request(app)
.get('/forum/tags');
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body[0].tagName).toBe('javascript');
});
});
describe('PATCH /forum/posts/:id/status', () => {
it('should update post status by author', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
status: 'open',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/status')
.send({ status: 'answered' });
expect(response.status).toBe(200);
expect(mockPost.update).toHaveBeenCalledWith({
status: 'answered',
closedBy: null,
closedAt: null,
});
});
it('should reject invalid status', async () => {
const mockPost = {
id: 'post-1',
authorId: 'user-123',
isDeleted: false,
};
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.patch('/forum/posts/post-1/status')
.send({ status: 'invalid-status' });
expect(response.status).toBe(400);
expect(response.body.error).toBe('Invalid status value');
});
});
describe('PUT /forum/comments/:id', () => {
it('should update own comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
content: 'Original',
isDeleted: false,
post: { id: 'post-1', isDeleted: false },
update: jest.fn().mockResolvedValue(),
reload: jest.fn().mockResolvedValue(),
toJSON: function() { return this; },
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated content' });
expect(response.status).toBe(200);
expect(mockComment.update).toHaveBeenCalledWith(
expect.objectContaining({
content: 'Updated content',
})
);
});
it('should return 403 when editing other users comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'other-user',
isDeleted: false,
post: { id: 'post-1', isDeleted: false },
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.put('/forum/comments/comment-1')
.send({ content: 'Updated' });
expect(response.status).toBe(403);
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.put('/forum/comments/non-existent')
.send({ content: 'Updated' });
expect(response.status).toBe(404);
});
});
describe('DELETE /forum/comments/:id', () => {
it('should soft delete own comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'user-123',
postId: 'post-1',
isDeleted: false,
update: jest.fn().mockResolvedValue(),
};
const mockPost = {
id: 'post-1',
commentCount: 5,
decrement: jest.fn().mockResolvedValue(),
};
ForumComment.findByPk.mockResolvedValue(mockComment);
ForumPost.findByPk.mockResolvedValue(mockPost);
const response = await request(app)
.delete('/forum/comments/comment-1');
// Returns 204 No Content on successful delete
expect(response.status).toBe(204);
expect(mockComment.update).toHaveBeenCalledWith({ isDeleted: true });
expect(mockPost.decrement).toHaveBeenCalledWith('commentCount');
});
it('should return 404 for non-existent comment', async () => {
ForumComment.findByPk.mockResolvedValue(null);
const response = await request(app)
.delete('/forum/comments/non-existent');
expect(response.status).toBe(404);
});
it('should return 403 when deleting other users comment', async () => {
const mockComment = {
id: 'comment-1',
authorId: 'other-user',
isDeleted: false,
};
ForumComment.findByPk.mockResolvedValue(mockComment);
const response = await request(app)
.delete('/forum/comments/comment-1');
expect(response.status).toBe(403);
});
});
});

View File

@@ -0,0 +1,107 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies
jest.mock('../../../models', () => ({
sequelize: {
authenticate: jest.fn(),
},
}));
jest.mock('../../../services/s3Service', () => ({
isEnabled: jest.fn(),
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
const { sequelize } = require('../../../models');
const s3Service = require('../../../services/s3Service');
const healthRoutes = require('../../../routes/health');
describe('Health Routes', () => {
let app;
beforeEach(() => {
app = express();
app.use('/health', healthRoutes);
jest.clearAllMocks();
});
describe('GET /health', () => {
it('should return 200 when all services are healthy', async () => {
sequelize.authenticate.mockResolvedValue();
s3Service.isEnabled.mockReturnValue(true);
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
it('should return 503 when database is unhealthy', async () => {
sequelize.authenticate.mockRejectedValue(new Error('Connection refused'));
s3Service.isEnabled.mockReturnValue(true);
const response = await request(app).get('/health');
expect(response.status).toBe(503);
expect(response.body).toEqual({ status: 'unhealthy' });
});
it('should return healthy when S3 is disabled but database is up', async () => {
sequelize.authenticate.mockResolvedValue();
s3Service.isEnabled.mockReturnValue(false);
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body).toEqual({ status: 'healthy' });
});
});
describe('GET /health/live', () => {
it('should return 200 alive status', async () => {
const response = await request(app).get('/health/live');
expect(response.status).toBe(200);
expect(response.body.status).toBe('alive');
expect(response.body).toHaveProperty('timestamp');
});
it('should always return 200 regardless of service state', async () => {
// Liveness probe should always pass if the process is running
sequelize.authenticate.mockRejectedValue(new Error('DB down'));
const response = await request(app).get('/health/live');
expect(response.status).toBe(200);
expect(response.body.status).toBe('alive');
});
});
describe('GET /health/ready', () => {
it('should return 200 when database is ready', async () => {
sequelize.authenticate.mockResolvedValue();
const response = await request(app).get('/health/ready');
expect(response.status).toBe(200);
expect(response.body.status).toBe('ready');
expect(response.body).toHaveProperty('timestamp');
});
it('should return 503 when database is not ready', async () => {
sequelize.authenticate.mockRejectedValue(new Error('Connection timeout'));
const response = await request(app).get('/health/ready');
expect(response.status).toBe(503);
expect(response.body.status).toBe('not_ready');
expect(response.body.error).toBe('Database connection failed');
});
});
});

View File

@@ -199,7 +199,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
limit: 20,
@@ -580,7 +580,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'renter',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
}
],
order: [['createdAt', 'DESC']]
@@ -648,7 +648,7 @@ describe('Items Routes', () => {
{
model: mockUserModel,
as: 'owner',
attributes: ['id', 'firstName', 'lastName']
attributes: ['id', 'firstName', 'lastName', 'imageFilename']
},
{
model: mockUserModel,

View File

@@ -143,7 +143,7 @@ describe('Rentals Routes', () => {
{
model: User,
as: 'owner',
attributes: ['id', 'firstName', 'lastName'],
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
},
],
order: [['createdAt', 'DESC']],
@@ -186,7 +186,7 @@ describe('Rentals Routes', () => {
{
model: User,
as: 'renter',
attributes: ['id', 'firstName', 'lastName'],
attributes: ['id', 'firstName', 'lastName', 'imageFilename'],
},
],
order: [['createdAt', 'DESC']],

View File

@@ -446,15 +446,137 @@ describe('Upload Routes', () => {
});
});
// Note: The GET /upload/signed-url/*key route uses Express 5 wildcard syntax
// which is not fully compatible with the test environment when mocking.
// The S3OwnershipService functionality is tested separately in s3OwnershipService.test.js
// The route integration is verified in integration tests.
describe('GET /upload/signed-url/*key (wildcard route)', () => {
it('should be defined as a route', () => {
// The route exists and is properly configured
// Full integration testing of wildcard routes is done in integration tests
expect(true).toBe(true);
describe('GET /upload/signed-url/:key(*)', () => {
const mockSignedUrl = 'https://bucket.s3.amazonaws.com/messages/uuid.jpg?signature=abc';
beforeEach(() => {
mockGetPresignedDownloadUrl.mockResolvedValue(mockSignedUrl);
mockCanAccessFile.mockResolvedValue({ authorized: true });
});
it('should return signed URL for authorized private content (messages)', async () => {
const response = await request(app)
.get('/upload/signed-url/messages/550e8400-e29b-41d4-a716-446655440000.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(200);
expect(response.body.url).toBe(mockSignedUrl);
expect(response.body.expiresIn).toBe(3600);
expect(mockCanAccessFile).toHaveBeenCalledWith(
'messages/550e8400-e29b-41d4-a716-446655440000.jpg',
'user-123'
);
expect(mockGetPresignedDownloadUrl).toHaveBeenCalledWith(
'messages/550e8400-e29b-41d4-a716-446655440000.jpg'
);
});
it('should return signed URL for authorized condition-check content', async () => {
const response = await request(app)
.get('/upload/signed-url/condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(200);
expect(response.body.url).toBe(mockSignedUrl);
expect(mockCanAccessFile).toHaveBeenCalledWith(
'condition-checks/550e8400-e29b-41d4-a716-446655440000.jpg',
'user-123'
);
});
it('should require authentication', async () => {
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg');
expect(response.status).toBe(401);
expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled();
});
it('should return 503 when S3 is disabled', async () => {
mockIsEnabled.mockReturnValue(false);
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(503);
});
it('should return 400 for public folder paths (items)', async () => {
const response = await request(app)
.get('/upload/signed-url/items/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Signed URLs only for private content');
expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled();
});
it('should return 400 for public folder paths (profiles)', async () => {
const response = await request(app)
.get('/upload/signed-url/profiles/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Signed URLs only for private content');
});
it('should return 400 for public folder paths (forum)', async () => {
const response = await request(app)
.get('/upload/signed-url/forum/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(400);
expect(response.body.error).toBe('Signed URLs only for private content');
});
it('should return 403 when user is not authorized to access file', async () => {
mockCanAccessFile.mockResolvedValue({
authorized: false,
reason: 'Not a participant in this message'
});
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(403);
expect(response.body.error).toBe('Access denied');
expect(mockGetPresignedDownloadUrl).not.toHaveBeenCalled();
});
it('should handle URL-encoded keys', async () => {
const response = await request(app)
.get('/upload/signed-url/messages%2Fuuid.jpg')
.set('Authorization', 'Bearer valid-token');
// The key should be decoded
expect(mockCanAccessFile).toHaveBeenCalledWith(
expect.stringContaining('messages'),
'user-123'
);
});
it('should handle S3 service errors gracefully', async () => {
mockGetPresignedDownloadUrl.mockRejectedValue(new Error('S3 error'));
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(500);
});
it('should handle ownership service errors gracefully', async () => {
mockCanAccessFile.mockRejectedValue(new Error('Database error'));
const response = await request(app)
.get('/upload/signed-url/messages/uuid.jpg')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(500);
});
});
});

View File

@@ -0,0 +1,352 @@
const UserService = require('../../../services/UserService');
const { User, UserAddress } = require('../../../models');
const emailServices = require('../../../services/email');
const logger = require('../../../utils/logger');
// Mock dependencies
jest.mock('../../../models', () => ({
User: {
findByPk: jest.fn(),
},
UserAddress: {
create: jest.fn(),
findOne: jest.fn(),
},
}));
jest.mock('../../../services/email', () => ({
auth: {
sendPersonalInfoChangedEmail: jest.fn().mockResolvedValue(),
},
}));
jest.mock('../../../utils/logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
}));
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.NODE_ENV = 'test';
});
describe('updateProfile', () => {
const mockUser = {
id: 'user-123',
email: 'original@example.com',
firstName: 'John',
lastName: 'Doe',
address1: '123 Main St',
address2: null,
city: 'New York',
state: 'NY',
zipCode: '10001',
country: 'USA',
update: jest.fn().mockResolvedValue(),
};
it('should update user profile successfully', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser) // First call to find user
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' }); // Second call for return
const updateData = { firstName: 'Jane' };
const result = await UserService.updateProfile('user-123', updateData);
expect(User.findByPk).toHaveBeenCalledWith('user-123');
expect(mockUser.update).toHaveBeenCalledWith({ firstName: 'Jane' }, {});
expect(result.firstName).toBe('Jane');
});
it('should throw error when user not found', async () => {
User.findByPk.mockResolvedValue(null);
await expect(UserService.updateProfile('non-existent', { firstName: 'Test' }))
.rejects.toThrow('User not found');
});
it('should trim email and ignore empty email', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { email: ' new@example.com ' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ email: 'new@example.com' }),
{}
);
});
it('should not update email if empty string', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { email: ' ' });
// Email should not be in the update call
expect(mockUser.update).toHaveBeenCalledWith({}, {});
});
it('should convert empty phone to null', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { phone: '' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ phone: null }),
{}
);
});
it('should trim phone number', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
await UserService.updateProfile('user-123', { phone: ' 555-1234 ' });
expect(mockUser.update).toHaveBeenCalledWith(
expect.objectContaining({ phone: '555-1234' }),
{}
);
});
it('should pass options to update call', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce(mockUser);
const mockTransaction = { id: 'tx-123' };
await UserService.updateProfile('user-123', { firstName: 'Jane' }, { transaction: mockTransaction });
expect(mockUser.update).toHaveBeenCalledWith(
expect.any(Object),
{ transaction: mockTransaction }
);
});
it('should not send email notification in test environment', async () => {
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await UserService.updateProfile('user-123', { firstName: 'Jane' });
// Email should not be sent in test environment
expect(emailServices.auth.sendPersonalInfoChangedEmail).not.toHaveBeenCalled();
});
it('should send email notification in production when personal info changes', async () => {
process.env.NODE_ENV = 'production';
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, firstName: 'Jane' });
await UserService.updateProfile('user-123', { firstName: 'Jane' });
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
expect(logger.info).toHaveBeenCalledWith(
'Personal information changed notification sent',
expect.objectContaining({
userId: 'user-123',
changedFields: ['firstName'],
})
);
});
it('should handle email notification failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email service down')
);
User.findByPk
.mockResolvedValueOnce(mockUser)
.mockResolvedValueOnce({ ...mockUser, email: 'new@example.com' });
// Should not throw despite email failure
const result = await UserService.updateProfile('user-123', { email: 'new@example.com' });
expect(result).toBeDefined();
expect(logger.error).toHaveBeenCalledWith(
'Failed to send personal information changed notification',
expect.objectContaining({
error: 'Email service down',
userId: 'user-123',
})
);
});
});
describe('createUserAddress', () => {
const mockUser = {
id: 'user-123',
email: 'user@example.com',
};
const addressData = {
label: 'Home',
address1: '456 Oak Ave',
city: 'Boston',
state: 'MA',
zipCode: '02101',
country: 'USA',
};
it('should create a new address successfully', async () => {
const mockAddress = { id: 'addr-123', ...addressData, userId: 'user-123' };
User.findByPk.mockResolvedValue(mockUser);
UserAddress.create.mockResolvedValue(mockAddress);
const result = await UserService.createUserAddress('user-123', addressData);
expect(User.findByPk).toHaveBeenCalledWith('user-123');
expect(UserAddress.create).toHaveBeenCalledWith({
...addressData,
userId: 'user-123',
});
expect(result.id).toBe('addr-123');
});
it('should throw error when user not found', async () => {
User.findByPk.mockResolvedValue(null);
await expect(UserService.createUserAddress('non-existent', addressData))
.rejects.toThrow('User not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockAddress = { id: 'addr-123', ...addressData };
User.findByPk.mockResolvedValue(mockUser);
UserAddress.create.mockResolvedValue(mockAddress);
await UserService.createUserAddress('user-123', addressData);
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
});
describe('updateUserAddress', () => {
const mockAddress = {
id: 'addr-123',
userId: 'user-123',
address1: '123 Old St',
update: jest.fn().mockResolvedValue(),
};
it('should update address successfully', async () => {
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
const result = await UserService.updateUserAddress('user-123', 'addr-123', {
address1: '789 New St',
});
expect(UserAddress.findOne).toHaveBeenCalledWith({
where: { id: 'addr-123', userId: 'user-123' },
});
expect(mockAddress.update).toHaveBeenCalledWith({ address1: '789 New St' });
expect(result.id).toBe('addr-123');
});
it('should throw error when address not found', async () => {
UserAddress.findOne.mockResolvedValue(null);
await expect(
UserService.updateUserAddress('user-123', 'non-existent', { address1: 'New' })
).rejects.toThrow('Address not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockUser = { id: 'user-123', email: 'user@example.com' };
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue(mockUser);
await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' });
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
it('should handle email failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email failed')
);
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
const result = await UserService.updateUserAddress('user-123', 'addr-123', { city: 'Chicago' });
expect(result).toBeDefined();
expect(logger.error).toHaveBeenCalled();
});
});
describe('deleteUserAddress', () => {
const mockAddress = {
id: 'addr-123',
userId: 'user-123',
destroy: jest.fn().mockResolvedValue(),
};
it('should delete address successfully', async () => {
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(UserAddress.findOne).toHaveBeenCalledWith({
where: { id: 'addr-123', userId: 'user-123' },
});
expect(mockAddress.destroy).toHaveBeenCalled();
});
it('should throw error when address not found', async () => {
UserAddress.findOne.mockResolvedValue(null);
await expect(
UserService.deleteUserAddress('user-123', 'non-existent')
).rejects.toThrow('Address not found');
});
it('should send notification in production', async () => {
process.env.NODE_ENV = 'production';
const mockUser = { id: 'user-123', email: 'user@example.com' };
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue(mockUser);
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(emailServices.auth.sendPersonalInfoChangedEmail).toHaveBeenCalledWith(mockUser);
});
it('should handle email failure gracefully', async () => {
process.env.NODE_ENV = 'production';
emailServices.auth.sendPersonalInfoChangedEmail.mockRejectedValueOnce(
new Error('Email failed')
);
UserAddress.findOne.mockResolvedValue(mockAddress);
User.findByPk.mockResolvedValue({ id: 'user-123', email: 'user@example.com' });
// Should not throw
await UserService.deleteUserAddress('user-123', 'addr-123');
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -103,8 +103,8 @@ describe('EmailClient', () => {
process.env = {
...originalEnv,
EMAIL_ENABLED: 'true',
SES_FROM_EMAIL: 'noreply@rentall.com',
SES_FROM_NAME: 'RentAll',
SES_FROM_EMAIL: 'noreply@villageshare.app',
SES_FROM_NAME: 'Village Share',
};
});
@@ -159,7 +159,7 @@ describe('EmailClient', () => {
);
expect(SendEmailCommand).toHaveBeenCalledWith({
Source: 'RentAll <noreply@rentall.com>',
Source: 'Village Share <noreply@villageshare.app>',
Destination: {
ToAddresses: ['test@example.com'],
},
@@ -237,7 +237,7 @@ describe('EmailClient', () => {
});
it('should add reply-to address if configured', async () => {
process.env.SES_REPLY_TO_EMAIL = 'support@rentall.com';
process.env.SES_REPLY_TO_EMAIL = 'support@villageshare.app';
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'msg-000' });
SESClient.mockImplementation(() => ({ send: mockSend }));
@@ -253,7 +253,7 @@ describe('EmailClient', () => {
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
ReplyToAddresses: ['support@rentall.com'],
ReplyToAddresses: ['support@villageshare.app'],
})
);
});

View File

@@ -186,7 +186,7 @@ describe('TemplateManager', () => {
// Should return fallback template content
expect(result).toContain('Test Title');
expect(result).toContain('Test Message');
expect(result).toContain('RentAll');
expect(result).toContain('Village Share');
});
it('should auto-initialize if not initialized', async () => {
@@ -275,7 +275,7 @@ describe('TemplateManager', () => {
expect(fallback).toContain('{{title}}');
expect(fallback).toContain('{{message}}');
expect(fallback).toContain('RentAll');
expect(fallback).toContain('Village Share');
});
});
});

View File

@@ -7,10 +7,10 @@
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="CommunityRentals.App - Rent gym equipment, tools, and musical instruments from your neighbors"
content="Village Share - Life is too expensive. Rent or borrow from your neighbors"
/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>CommunityRentals.App - Equipment & Tool Rental Marketplace</title>
<title>Village Share - Community Rental Marketplace</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"

View File

@@ -0,0 +1,425 @@
/**
* AuthModal Component Tests
*
* Tests for the AuthModal component including login, signup,
* form validation, and modal behavior.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AuthModal from '../../components/AuthModal';
// Mock the auth context
const mockLogin = jest.fn();
const mockRegister = jest.fn();
jest.mock('../../contexts/AuthContext', () => ({
...jest.requireActual('../../contexts/AuthContext'),
useAuth: () => ({
login: mockLogin,
register: mockRegister,
user: null,
loading: false,
}),
}));
// Mock child components
jest.mock('../../components/PasswordStrengthMeter', () => {
return function MockPasswordStrengthMeter({ password }: { password: string }) {
return <div data-testid="password-strength-meter">Strength: {password.length > 8 ? 'Strong' : 'Weak'}</div>;
};
});
jest.mock('../../components/PasswordInput', () => {
return function MockPasswordInput({
id,
label,
value,
onChange,
required
}: {
id: string;
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
required?: boolean;
}) {
return (
<div className="mb-3">
<label htmlFor={id} className="form-label">{label}</label>
<input
id={id}
type="password"
className="form-control"
value={value}
onChange={onChange}
required={required}
data-testid="password-input"
/>
</div>
);
};
});
jest.mock('../../components/ForgotPasswordModal', () => {
return function MockForgotPasswordModal({
show,
onHide,
onBackToLogin
}: {
show: boolean;
onHide: () => void;
onBackToLogin: () => void;
}) {
if (!show) return null;
return (
<div data-testid="forgot-password-modal">
<button onClick={onBackToLogin} data-testid="back-to-login">Back to Login</button>
<button onClick={onHide}>Close</button>
</div>
);
};
});
jest.mock('../../components/VerificationCodeModal', () => {
return function MockVerificationCodeModal({
show,
onHide,
email,
onVerified
}: {
show: boolean;
onHide: () => void;
email: string;
onVerified: () => void;
}) {
if (!show) return null;
return (
<div data-testid="verification-modal">
<p>Verify email: {email}</p>
<button onClick={onVerified} data-testid="verify-button">Verify</button>
<button onClick={onHide}>Close</button>
</div>
);
};
});
describe('AuthModal', () => {
const defaultProps = {
show: true,
onHide: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
});
// Helper to get email input (it's a textbox with type email)
const getEmailInput = () => screen.getByRole('textbox', { hidden: false });
// Helper to get inputs by their preceding label text
const getInputByLabelText = (container: HTMLElement, labelText: string) => {
const label = Array.from(container.querySelectorAll('label')).find(
l => l.textContent === labelText
);
if (!label) throw new Error(`Label "${labelText}" not found`);
// Get the next sibling input or the input inside the same parent
const parent = label.parentElement;
return parent?.querySelector('input') as HTMLInputElement;
};
describe('Rendering', () => {
it('should render login form by default', () => {
const { container } = render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Welcome to Village Share')).toBeInTheDocument();
expect(getInputByLabelText(container, 'Email')).toBeInTheDocument();
expect(screen.getByTestId('password-input')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
});
it('should render signup form when initialMode is signup', () => {
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
expect(getInputByLabelText(container, 'First Name')).toBeInTheDocument();
expect(getInputByLabelText(container, 'Last Name')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
expect(screen.getByTestId('password-strength-meter')).toBeInTheDocument();
});
it('should not render when show is false', () => {
render(<AuthModal {...defaultProps} show={false} />);
expect(screen.queryByText('Welcome to Village Share')).not.toBeInTheDocument();
});
it('should render Google login button', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument();
});
it('should render forgot password link in login mode', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
});
it('should not render forgot password link in signup mode', () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
expect(screen.queryByText('Forgot password?')).not.toBeInTheDocument();
});
});
describe('Mode Switching', () => {
it('should switch from login to signup mode', async () => {
const { container } = render(<AuthModal {...defaultProps} />);
// Initially in login mode
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
// Click "Sign up" link
fireEvent.click(screen.getByText('Sign up'));
// Should now be in signup mode
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
expect(getInputByLabelText(container, 'First Name')).toBeInTheDocument();
});
it('should switch from signup to login mode', async () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
// Initially in signup mode
expect(screen.getByRole('button', { name: 'Sign up' })).toBeInTheDocument();
// Click "Log in" link
fireEvent.click(screen.getByText('Log in'));
// Should now be in login mode
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(screen.queryByText('First Name')).not.toBeInTheDocument();
});
});
describe('Login Form Submission', () => {
it('should call login with email and password', async () => {
mockLogin.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} />);
// Fill in the form
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
// Submit the form
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123');
});
});
it('should call onHide after successful login', async () => {
mockLogin.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(defaultProps.onHide).toHaveBeenCalled();
});
});
it('should display error message on login failure', async () => {
mockLogin.mockRejectedValue({
response: { data: { error: 'Invalid credentials' } },
});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'wrongpassword');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(screen.getByText('Invalid credentials')).toBeInTheDocument();
});
});
it('should show loading state during login', async () => {
// Make login take some time
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
expect(screen.getByRole('button', { name: 'Loading...' })).toBeInTheDocument();
});
});
describe('Signup Form Submission', () => {
it('should call register with user data', async () => {
mockRegister.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => {
expect(mockRegister).toHaveBeenCalledWith({
email: 'john@example.com',
password: 'StrongPass123!',
firstName: 'John',
lastName: 'Doe',
username: 'john', // Generated from email
});
});
});
it('should show verification modal after successful signup', async () => {
mockRegister.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => {
expect(screen.getByTestId('verification-modal')).toBeInTheDocument();
expect(screen.getByText('Verify email: john@example.com')).toBeInTheDocument();
});
});
it('should display error message on signup failure', async () => {
mockRegister.mockRejectedValue({
response: { data: { error: 'Email already exists' } },
});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
await waitFor(() => {
expect(screen.getByText('Email already exists')).toBeInTheDocument();
});
});
});
describe('Forgot Password', () => {
it('should show forgot password modal when link is clicked', async () => {
render(<AuthModal {...defaultProps} />);
fireEvent.click(screen.getByText('Forgot password?'));
expect(screen.getByTestId('forgot-password-modal')).toBeInTheDocument();
});
it('should hide forgot password modal and show login when back is clicked', async () => {
render(<AuthModal {...defaultProps} />);
// Open forgot password modal
fireEvent.click(screen.getByText('Forgot password?'));
expect(screen.getByTestId('forgot-password-modal')).toBeInTheDocument();
// Click back to login
fireEvent.click(screen.getByTestId('back-to-login'));
// Should show login form again
await waitFor(() => {
expect(screen.queryByTestId('forgot-password-modal')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
});
});
});
describe('Modal Close', () => {
it('should call onHide when close button is clicked', async () => {
render(<AuthModal {...defaultProps} />);
// Click close button (btn-close class)
const closeButton = document.querySelector('.btn-close') as HTMLButtonElement;
fireEvent.click(closeButton);
expect(defaultProps.onHide).toHaveBeenCalled();
});
});
describe('Google OAuth', () => {
it('should redirect to Google OAuth when Google button is clicked', () => {
// Mock window.location
const originalLocation = window.location;
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
render(<AuthModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /continue with google/i }));
// Check that window.location.href was set to Google OAuth URL
expect(window.location.href).toContain('accounts.google.com');
// Restore
window.location = originalLocation;
});
});
describe('Accessibility', () => {
it('should have password label associated with input', () => {
render(<AuthModal {...defaultProps} initialMode="signup" />);
// Password input has proper htmlFor through the mock
expect(screen.getByLabelText('Password')).toBeInTheDocument();
});
it('should display error in an alert role', async () => {
mockLogin.mockRejectedValue({
response: { data: { error: 'Test error' } },
});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});
describe('Terms and Privacy Links', () => {
it('should display terms and privacy links', () => {
render(<AuthModal {...defaultProps} />);
expect(screen.getByText('Terms of Service')).toHaveAttribute('href', '/terms');
expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', '/privacy');
});
});
});

View File

@@ -0,0 +1,268 @@
/**
* Navbar Component Tests
*
* Tests for the Navbar component including navigation links,
* user authentication state, search functionality, and notifications.
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Navbar from '../../components/Navbar';
import { rentalAPI, messageAPI } from '../../services/api';
// Mock dependencies
jest.mock('../../services/api', () => ({
rentalAPI: {
getPendingRequestsCount: jest.fn(),
},
messageAPI: {
getUnreadCount: jest.fn(),
},
}));
// Mock socket context
jest.mock('../../contexts/SocketContext', () => ({
useSocket: () => ({
onNewMessage: jest.fn(() => () => {}),
onMessageRead: jest.fn(() => () => {}),
}),
}));
// Variable to control auth state per test
let mockUser: any = null;
const mockLogout = jest.fn();
const mockOpenAuthModal = jest.fn();
jest.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
logout: mockLogout,
openAuthModal: mockOpenAuthModal,
}),
}));
// Mock useNavigate
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
// Helper to render with Router
const renderWithRouter = (component: React.ReactElement) => {
return render(<BrowserRouter>{component}</BrowserRouter>);
};
describe('Navbar', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUser = null;
// Default mock implementations
(rentalAPI.getPendingRequestsCount as jest.Mock).mockResolvedValue({ data: { count: 0 } });
(messageAPI.getUnreadCount as jest.Mock).mockResolvedValue({ data: { count: 0 } });
});
describe('Branding', () => {
it('should display the brand name', () => {
renderWithRouter(<Navbar />);
expect(screen.getByText('Village Share')).toBeInTheDocument();
});
it('should link brand to home page', () => {
renderWithRouter(<Navbar />);
const brandLink = screen.getByRole('link', { name: /Village Share/i });
expect(brandLink).toHaveAttribute('href', '/');
});
});
describe('Search Functionality', () => {
it('should render search input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('Search items...')).toBeInTheDocument();
});
it('should render location input', () => {
renderWithRouter(<Navbar />);
expect(screen.getByPlaceholderText('City or ZIP')).toBeInTheDocument();
});
it('should render search button', () => {
renderWithRouter(<Navbar />);
// Search button has an icon
const searchButton = document.querySelector('.bi-search');
expect(searchButton).toBeInTheDocument();
});
it('should navigate to items page when search is submitted', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
fireEvent.change(searchInput, { target: { value: 'camera' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=camera');
});
it('should handle Enter key in search input', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
fireEvent.change(searchInput, { target: { value: 'tent' } });
fireEvent.keyDown(searchInput, { key: 'Enter' });
expect(mockNavigate).toHaveBeenCalledWith('/items?search=tent');
});
it('should append city to search URL', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
const locationInput = screen.getByPlaceholderText('City or ZIP');
fireEvent.change(searchInput, { target: { value: 'kayak' } });
fireEvent.change(locationInput, { target: { value: 'Seattle' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=kayak&city=Seattle');
});
it('should append zipCode when location is a zip code', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...');
const locationInput = screen.getByPlaceholderText('City or ZIP');
fireEvent.change(searchInput, { target: { value: 'bike' } });
fireEvent.change(locationInput, { target: { value: '98101' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(mockNavigate).toHaveBeenCalledWith('/items?search=bike&zipCode=98101');
});
it('should clear search fields after search', () => {
renderWithRouter(<Navbar />);
const searchInput = screen.getByPlaceholderText('Search items...') as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: 'camera' } });
const searchButton = document.querySelector('button.btn-outline-secondary') as HTMLButtonElement;
fireEvent.click(searchButton);
expect(searchInput.value).toBe('');
});
});
describe('Logged Out State', () => {
it('should show login button when user is not logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('button', { name: 'Login or Sign Up' })).toBeInTheDocument();
});
it('should call openAuthModal when login button is clicked', () => {
renderWithRouter(<Navbar />);
fireEvent.click(screen.getByRole('button', { name: 'Login or Sign Up' }));
expect(mockOpenAuthModal).toHaveBeenCalledWith('login');
});
});
describe('Logged In State', () => {
beforeEach(() => {
mockUser = {
id: 'user-123',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
};
});
it('should show user name when logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.getByText('John')).toBeInTheDocument();
});
it('should not show login button when logged in', () => {
renderWithRouter(<Navbar />);
expect(screen.queryByRole('button', { name: 'Login or Sign Up' })).not.toBeInTheDocument();
});
it('should show profile link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Profile/i })).toHaveAttribute('href', '/profile');
});
it('should show renting link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Renting/i })).toHaveAttribute('href', '/renting');
});
it('should show owning link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Owning/i })).toHaveAttribute('href', '/owning');
});
it('should show messages link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Messages/i })).toHaveAttribute('href', '/messages');
});
it('should show forum link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Forum/i })).toHaveAttribute('href', '/forum');
});
it('should show earnings link in dropdown', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: /Earnings/i })).toHaveAttribute('href', '/earnings');
});
it('should call logout and navigate home when logout is clicked', () => {
renderWithRouter(<Navbar />);
const logoutButton = screen.getByRole('button', { name: /Logout/i });
fireEvent.click(logoutButton);
expect(mockLogout).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
describe('Start Earning Link', () => {
it('should show Start Earning link', () => {
renderWithRouter(<Navbar />);
expect(screen.getByRole('link', { name: 'Start Earning' })).toHaveAttribute('href', '/create-item');
});
});
describe('Mobile Navigation', () => {
it('should render mobile toggle button', () => {
renderWithRouter(<Navbar />);
expect(screen.getByLabelText('Toggle navigation')).toBeInTheDocument();
});
});
});

View File

@@ -23,12 +23,88 @@ jest.mock('../../services/api');
const mockedApi = api as jest.Mocked<typeof api>;
// Mock XMLHttpRequest for uploadToS3 tests
class MockXMLHttpRequest {
static instances: MockXMLHttpRequest[] = [];
status = 200;
readyState = 4;
responseText = '';
upload = {
onprogress: null as ((e: { lengthComputable: boolean; loaded: number; total: number }) => void) | null,
};
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
private headers: Record<string, string> = {};
private method = '';
private url = '';
constructor() {
MockXMLHttpRequest.instances.push(this);
}
open(method: string, url: string) {
this.method = method;
this.url = url;
}
setRequestHeader(key: string, value: string) {
this.headers[key] = value;
}
send(_data: unknown) {
// Use Promise.resolve().then for async behavior in tests
// This allows promises to resolve without real delays
Promise.resolve().then(() => {
if (this.upload.onprogress) {
this.upload.onprogress({ lengthComputable: true, loaded: 50, total: 100 });
this.upload.onprogress({ lengthComputable: true, loaded: 100, total: 100 });
}
if (this.onload) {
this.onload();
}
});
}
getHeaders() {
return this.headers;
}
getMethod() {
return this.method;
}
getUrl() {
return this.url;
}
static reset() {
MockXMLHttpRequest.instances = [];
}
static getLastInstance() {
return MockXMLHttpRequest.instances[MockXMLHttpRequest.instances.length - 1];
}
}
// Store original XMLHttpRequest
const originalXMLHttpRequest = global.XMLHttpRequest;
describe('Upload Service', () => {
beforeEach(() => {
jest.clearAllMocks();
MockXMLHttpRequest.reset();
// Reset environment variables
process.env.REACT_APP_S3_BUCKET = 'test-bucket';
process.env.REACT_APP_AWS_REGION = 'us-east-1';
// Mock XMLHttpRequest globally
(global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }).XMLHttpRequest = MockXMLHttpRequest;
});
afterEach(() => {
// Restore original XMLHttpRequest
(global as unknown as { XMLHttpRequest: typeof XMLHttpRequest }).XMLHttpRequest = originalXMLHttpRequest;
});
describe('getPublicImageUrl', () => {
@@ -173,18 +249,42 @@ describe('Upload Service', () => {
});
describe('uploadToS3', () => {
// Note: XMLHttpRequest mocking is complex and can cause timeouts.
// The uploadToS3 function is a thin wrapper around XHR.
// Testing focuses on verifying the function signature and basic behavior.
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' });
const mockUploadUrl = 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc';
it('should export uploadToS3 function', () => {
expect(typeof uploadToS3).toBe('function');
it('should upload file successfully', async () => {
await uploadToS3(mockFile, mockUploadUrl);
const instance = MockXMLHttpRequest.getLastInstance();
expect(instance.getMethod()).toBe('PUT');
expect(instance.getUrl()).toBe(mockUploadUrl);
expect(instance.getHeaders()['Content-Type']).toBe('image/jpeg');
});
it('should accept file, url, and options parameters', () => {
// Verify function signature
it('should call onProgress callback during upload', async () => {
const onProgress = jest.fn();
await uploadToS3(mockFile, mockUploadUrl, { onProgress });
// Progress should be called at least once
expect(onProgress).toHaveBeenCalled();
// Should receive percentage values
expect(onProgress).toHaveBeenCalledWith(expect.any(Number));
});
it('should export uploadToS3 function with correct signature', () => {
expect(typeof uploadToS3).toBe('function');
// Function accepts file, url, and optional options
expect(uploadToS3.length).toBeGreaterThanOrEqual(2);
});
it('should set correct content-type header', async () => {
const pngFile = new File(['test'], 'image.png', { type: 'image/png' });
await uploadToS3(pngFile, mockUploadUrl);
const instance = MockXMLHttpRequest.getLastInstance();
expect(instance.getHeaders()['Content-Type']).toBe('image/png');
});
});
describe('confirmUploads', () => {
@@ -214,70 +314,230 @@ describe('Upload Service', () => {
});
describe('uploadFile', () => {
it('should call getPresignedUrl and confirmUploads in sequence', async () => {
// Test the flow without mocking XMLHttpRequest (which is complex)
// Instead test that the functions are called with correct parameters
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' });
const presignResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned.s3.amazonaws.com',
key: 'items/uuid.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
expiresAt: new Date().toISOString(),
};
const mockFile = new File(['test content'], 'photo.jpg', { type: 'image/jpeg' });
const presignResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg',
key: 'items/uuid.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
expiresAt: new Date().toISOString(),
};
it('should complete full upload flow successfully', async () => {
// Mock presign response
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
// Mock confirm response
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [presignResponse.key], total: 1 },
});
// Just test getPresignedUrl is called correctly
await getPresignedUrl('item', file);
const result = await uploadFile('item', mockFile);
expect(result).toEqual({
key: presignResponse.key,
publicUrl: presignResponse.publicUrl,
});
// Verify presign was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', {
uploadType: 'item',
contentType: 'image/jpeg',
fileName: 'photo.jpg',
fileSize: file.size,
fileSize: mockFile.size,
});
// Verify confirm was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
keys: [presignResponse.key],
});
});
it('should throw error when upload verification fails', async () => {
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
// Mock confirm returning empty confirmed array
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [], total: 1 },
});
await expect(uploadFile('item', mockFile)).rejects.toThrow('Upload verification failed');
});
it('should pass onProgress to uploadToS3', async () => {
const onProgress = jest.fn();
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [presignResponse.key], total: 1 },
});
await uploadFile('item', mockFile, { onProgress });
// onProgress should have been called during XHR upload
expect(onProgress).toHaveBeenCalled();
});
it('should work with different upload types', async () => {
const messagePresignResponse = {
...presignResponse,
key: 'messages/uuid.jpg',
publicUrl: null, // Messages are private
};
mockedApi.post.mockResolvedValueOnce({ data: messagePresignResponse });
mockedApi.post.mockResolvedValueOnce({
data: { confirmed: [messagePresignResponse.key], total: 1 },
});
const result = await uploadFile('message', mockFile);
expect(result.key).toBe('messages/uuid.jpg');
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign', expect.objectContaining({
uploadType: 'message',
}));
});
});
describe('uploadFiles', () => {
const mockFiles = [
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['test2'], 'photo2.png', { type: 'image/png' }),
];
const presignResponses: PresignedUrlResponse[] = [
{
uploadUrl: 'https://presigned1.s3.amazonaws.com/items/uuid1.jpg',
key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
expiresAt: new Date().toISOString(),
},
{
uploadUrl: 'https://presigned2.s3.amazonaws.com/items/uuid2.png',
key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
expiresAt: new Date().toISOString(),
},
];
it('should return empty array for empty files array', async () => {
const result = await uploadFiles('item', []);
expect(result).toEqual([]);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('should call getPresignedUrls with correct parameters', async () => {
const files = [
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['test2'], 'photo2.png', { type: 'image/png' }),
];
const presignResponses: PresignedUrlResponse[] = [
{
uploadUrl: 'https://presigned1.s3.amazonaws.com',
key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
expiresAt: new Date().toISOString(),
},
{
uploadUrl: 'https://presigned2.s3.amazonaws.com',
key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
expiresAt: new Date().toISOString(),
},
];
it('should complete full batch upload flow successfully', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: presignResponses.map((p) => p.key),
total: 2,
},
});
await getPresignedUrls('item', files);
const result = await uploadFiles('item', mockFiles);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
});
expect(result[1]).toEqual({
key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
});
// Verify batch presign was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', {
uploadType: 'item',
files: [
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: files[0].size },
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: files[1].size },
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: mockFiles[0].size },
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size },
],
});
// Verify confirm was called with all keys
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
keys: ['items/uuid1.jpg', 'items/uuid2.png'],
});
});
it('should filter out unconfirmed uploads', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
// Only first file confirmed
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: ['items/uuid1.jpg'],
total: 2,
},
});
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await uploadFiles('item', mockFiles);
// Only confirmed uploads should be returned
expect(result).toHaveLength(1);
expect(result[0].key).toBe('items/uuid1.jpg');
// Should log warning about failed verification
expect(consoleSpy).toHaveBeenCalledWith('1 uploads failed verification');
consoleSpy.mockRestore();
});
it('should handle all uploads failing verification', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: [],
total: 2,
},
});
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await uploadFiles('item', mockFiles);
expect(result).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith('2 uploads failed verification');
consoleSpy.mockRestore();
});
it('should upload all files in parallel', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: presignResponses.map((p) => p.key),
total: 2,
},
});
await uploadFiles('item', mockFiles);
// Should have created 2 XHR instances for parallel uploads
expect(MockXMLHttpRequest.instances.length).toBe(2);
});
it('should work with different upload types', async () => {
const forumResponses = presignResponses.map((r) => ({
...r,
key: r.key.replace('items/', 'forum/'),
publicUrl: r.publicUrl.replace('items/', 'forum/'),
}));
mockedApi.post.mockResolvedValueOnce({ data: { uploads: forumResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: forumResponses.map((p) => p.key),
total: 2,
},
});
const result = await uploadFiles('forum', mockFiles);
expect(result[0].key).toBe('forum/uuid1.jpg');
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', expect.objectContaining({
uploadType: 'forum',
}));
});
});

View File

@@ -74,7 +74,7 @@ const AlphaGate: React.FC = () => {
<div className="card-body p-5">
<div className="text-center mb-4">
<h1 className="h2 mb-3" style={{ color: "#667eea" }}>
Community Rentals
Village Share
</h1>
</div>
@@ -105,7 +105,7 @@ const AlphaGate: React.FC = () => {
Currently in Alpha Testing!
</h6>
<p className="text-muted small mb-0 text-center">
You're among the first to try Community Rentals! Help us create
You're among the first to try Village Share! Help us create
something special by sharing your thoughts as we build this
together.
</p>
@@ -115,7 +115,7 @@ const AlphaGate: React.FC = () => {
<p className="text-center text-muted small mb-0">
Have an alpha code? Get started below! <br></br> Want to join?{" "}
<a
href="mailto:support@communityrentals.app?subject=Alpha Access Request"
href="mailto:support@villageshare.app?subject=Alpha Access Request"
className="text-decoration-none"
style={{ color: "#667eea" }}
>

View File

@@ -181,7 +181,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
</div>
<div className="modal-body px-4 pb-4">
<h4 className="text-center mb-2">
Welcome to CommunityRentals.App
Welcome to Village Share
</h4>
{error && (
@@ -307,7 +307,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
</div>
<p className="text-center text-muted small mt-4 mb-0">
By continuing, you agree to CommunityRentals.App's{" "}
By continuing, you agree to Village Share's{" "}
<a href="/terms" className="text-decoration-none">
Terms of Service
</a>{" "}

View File

@@ -84,14 +84,14 @@ const FeedbackModal: React.FC<FeedbackModalProps> = ({ show, onClose }) => {
<h6 className="alert-heading">Thank you!</h6>
<p className="mb-0">
Your feedback has been submitted successfully! We appreciate
you making Community Rentals better!
you making Village Share better!
</p>
</div>
) : (
<>
<p className="text-muted mb-3">
Share your thoughts, report bugs, or suggest improvements.
Your feedback helps us make RentAll better for everyone!
Your feedback helps us make Village Share better for everyone!
</p>
{error && (

View File

@@ -47,7 +47,7 @@ const Footer: React.FC = () => {
{/* Copyright */}
<p className="small text-white-50 mb-0">
© 2025 CommunityRentals.App. All rights reserved.
© 2025 Village Share. All rights reserved.
</p>
</div>
</div>

View File

@@ -126,7 +126,7 @@ const Navbar: React.FC = () => {
<div className="container-fluid" style={{ maxWidth: "1800px" }}>
<Link className="navbar-brand fw-bold" to="/">
<i className="bi bi-box-seam me-2"></i>
CommunityRentals.App
Village Share
</Link>
<button
className="navbar-toggler"

View File

@@ -91,7 +91,7 @@ const Home: React.FC = () => {
<div className="row align-items-center">
<div className="col-lg-6">
<h2 className="mb-4" style={{ color: "white" }}>
Why Choose CommunityRentals.App?
Why Choose Village Share?
</h2>
<div className="d-flex mb-3">
<i