Compare commits
2 Commits
71ce2c63fb
...
16272ba373
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16272ba373 | ||
|
|
99aa0b3bdc |
@@ -310,6 +310,22 @@ const validateVerifyResetToken = [
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
// Feedback validation
|
||||
const validateFeedback = [
|
||||
body("feedbackText")
|
||||
.trim()
|
||||
.isLength({ min: 5, max: 5000 })
|
||||
.withMessage("Feedback must be between 5 and 5000 characters"),
|
||||
|
||||
body("url")
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ max: 500 })
|
||||
.withMessage("URL must be less than 500 characters"),
|
||||
|
||||
handleValidationErrors,
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
sanitizeInput,
|
||||
handleValidationErrors,
|
||||
@@ -321,4 +337,5 @@ module.exports = {
|
||||
validateForgotPassword,
|
||||
validateResetPassword,
|
||||
validateVerifyResetToken,
|
||||
validateFeedback,
|
||||
};
|
||||
|
||||
34
backend/models/Feedback.js
Normal file
34
backend/models/Feedback.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/database');
|
||||
|
||||
const Feedback = sequelize.define('Feedback', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
feedbackText: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
userAgent: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
url: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
module.exports = Feedback;
|
||||
@@ -8,6 +8,7 @@ const ItemRequestResponse = require("./ItemRequestResponse");
|
||||
const UserAddress = require("./UserAddress");
|
||||
const ConditionCheck = require("./ConditionCheck");
|
||||
const AlphaInvitation = require("./AlphaInvitation");
|
||||
const Feedback = require("./Feedback");
|
||||
|
||||
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
||||
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||
@@ -82,6 +83,10 @@ User.hasMany(AlphaInvitation, {
|
||||
foreignKey: "usedBy",
|
||||
});
|
||||
|
||||
// Feedback associations
|
||||
User.hasMany(Feedback, { as: "feedbacks", foreignKey: "userId" });
|
||||
Feedback.belongsTo(User, { as: "user", foreignKey: "userId" });
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
User,
|
||||
@@ -93,4 +98,5 @@ module.exports = {
|
||||
UserAddress,
|
||||
ConditionCheck,
|
||||
AlphaInvitation,
|
||||
Feedback,
|
||||
};
|
||||
|
||||
66
backend/routes/feedback.js
Normal file
66
backend/routes/feedback.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const { Feedback, User } = require('../models');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { validateFeedback, sanitizeInput } = require('../middleware/validation');
|
||||
const logger = require('../utils/logger');
|
||||
const emailService = require('../services/emailService');
|
||||
const router = express.Router();
|
||||
|
||||
// Submit new feedback
|
||||
router.post('/', authenticateToken, sanitizeInput, validateFeedback, async (req, res) => {
|
||||
try {
|
||||
const { feedbackText, url } = req.body;
|
||||
|
||||
// Capture user agent from request headers
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
const feedback = await Feedback.create({
|
||||
userId: req.user.id,
|
||||
feedbackText,
|
||||
url: url || null,
|
||||
userAgent: userAgent || null
|
||||
});
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Feedback submitted", {
|
||||
userId: req.user.id,
|
||||
feedbackId: feedback.id
|
||||
});
|
||||
|
||||
// Send confirmation email to user
|
||||
try {
|
||||
await emailService.sendFeedbackConfirmation(req.user, feedback);
|
||||
} catch (emailError) {
|
||||
reqLogger.error("Failed to send feedback confirmation email", {
|
||||
error: emailError.message,
|
||||
userId: req.user.id,
|
||||
feedbackId: feedback.id
|
||||
});
|
||||
// Don't fail the request if email fails
|
||||
}
|
||||
|
||||
// Send notification email to admin
|
||||
try {
|
||||
await emailService.sendFeedbackNotificationToAdmin(req.user, feedback);
|
||||
} catch (emailError) {
|
||||
reqLogger.error("Failed to send feedback notification to admin", {
|
||||
error: emailError.message,
|
||||
userId: req.user.id,
|
||||
feedbackId: feedback.id
|
||||
});
|
||||
// Don't fail the request if email fails
|
||||
}
|
||||
|
||||
res.status(201).json(feedback);
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Feedback submission failed", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -109,6 +109,28 @@ router.get("/my-listings", authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get count of pending rental requests for owner
|
||||
router.get("/pending-requests-count", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const count = await Rental.count({
|
||||
where: {
|
||||
ownerId: req.user.id,
|
||||
status: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.error("Error getting pending rental count", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
userId: req.user.id,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get pending rental count" });
|
||||
}
|
||||
});
|
||||
|
||||
// Get rental by ID
|
||||
router.get("/:id", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ const itemRequestRoutes = require("./routes/itemRequests");
|
||||
const stripeRoutes = require("./routes/stripe");
|
||||
const mapsRoutes = require("./routes/maps");
|
||||
const conditionCheckRoutes = require("./routes/conditionChecks");
|
||||
const feedbackRoutes = require("./routes/feedback");
|
||||
|
||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
const RentalStatusJob = require("./jobs/rentalStatusJob");
|
||||
@@ -126,6 +127,7 @@ app.use("/api/item-requests", requireAlphaAccess, itemRequestRoutes);
|
||||
app.use("/api/stripe", requireAlphaAccess, stripeRoutes);
|
||||
app.use("/api/maps", requireAlphaAccess, mapsRoutes);
|
||||
app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes);
|
||||
app.use("/api/feedback", requireAlphaAccess, feedbackRoutes);
|
||||
|
||||
// Error handling middleware (must be last)
|
||||
app.use(errorLogger);
|
||||
|
||||
@@ -52,6 +52,8 @@ class EmailService {
|
||||
"payoutReceivedToOwner.html",
|
||||
"firstListingCelebrationToOwner.html",
|
||||
"alphaInvitationToUser.html",
|
||||
"feedbackConfirmationToUser.html",
|
||||
"feedbackNotificationToAdmin.html",
|
||||
];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
@@ -443,6 +445,40 @@ class EmailService {
|
||||
<p><a href="{{myListingsUrl}}" class="button">View My Listings</a></p>
|
||||
`
|
||||
),
|
||||
|
||||
feedbackConfirmationToUser: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<p>Hi {{userName}},</p>
|
||||
<h2>Thank You for Your Feedback!</h2>
|
||||
<p>We've received your feedback and our team will review it carefully.</p>
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; font-style: italic;">
|
||||
{{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>If your feedback requires a response, our team will reach out to you directly.</p>
|
||||
`
|
||||
),
|
||||
|
||||
feedbackNotificationToAdmin: baseTemplate.replace(
|
||||
"{{content}}",
|
||||
`
|
||||
<h2>New Feedback Received</h2>
|
||||
<p><strong>From:</strong> {{userName}} ({{userEmail}})</p>
|
||||
<p><strong>User ID:</strong> {{userId}}</p>
|
||||
<p><strong>Submitted:</strong> {{submittedAt}}</p>
|
||||
<h3>Feedback Content</h3>
|
||||
<div style="background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 20px; margin: 20px 0;">
|
||||
{{feedbackText}}
|
||||
</div>
|
||||
<h3>Technical Context</h3>
|
||||
<p><strong>Feedback ID:</strong> {{feedbackId}}</p>
|
||||
<p><strong>Page URL:</strong> {{url}}</p>
|
||||
<p><strong>User Agent:</strong> {{userAgent}}</p>
|
||||
<p>Please review this feedback and take appropriate action if needed.</p>
|
||||
`
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1750,6 +1786,69 @@ class EmailService {
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async sendFeedbackConfirmation(user, feedback) {
|
||||
const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const variables = {
|
||||
userName: user.firstName || "there",
|
||||
userEmail: user.email,
|
||||
feedbackText: feedback.feedbackText,
|
||||
submittedAt: submittedAt,
|
||||
year: new Date().getFullYear(),
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"feedbackConfirmationToUser",
|
||||
variables
|
||||
);
|
||||
|
||||
return await this.sendEmail(
|
||||
user.email,
|
||||
"Thank You for Your Feedback - RentAll",
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
|
||||
async sendFeedbackNotificationToAdmin(user, feedback) {
|
||||
const adminEmail = process.env.FEEDBACK_EMAIL || process.env.CUSTOMER_SUPPORT_EMAIL;
|
||||
|
||||
if (!adminEmail) {
|
||||
console.warn("No admin email configured for feedback notifications");
|
||||
return { success: false, error: "No admin email configured" };
|
||||
}
|
||||
|
||||
const submittedAt = new Date(feedback.createdAt).toLocaleString("en-US", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
const variables = {
|
||||
userName: `${user.firstName} ${user.lastName}`.trim() || "Unknown User",
|
||||
userEmail: user.email,
|
||||
userId: user.id,
|
||||
feedbackText: feedback.feedbackText,
|
||||
feedbackId: feedback.id,
|
||||
url: feedback.url || "Not provided",
|
||||
userAgent: feedback.userAgent || "Not provided",
|
||||
submittedAt: submittedAt,
|
||||
year: new Date().getFullYear(),
|
||||
};
|
||||
|
||||
const htmlContent = this.renderTemplate(
|
||||
"feedbackNotificationToAdmin",
|
||||
variables
|
||||
);
|
||||
|
||||
return await this.sendEmail(
|
||||
adminEmail,
|
||||
`New Feedback from ${user.firstName} ${user.lastName}`,
|
||||
htmlContent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
|
||||
117
backend/templates/emails/feedbackConfirmationToUser.html
Normal file
117
backend/templates/emails/feedbackConfirmationToUser.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Feedback Received - RentAll</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.content {
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.success-box .icon {
|
||||
font-size: 32px;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.success-box p {
|
||||
margin: 5px 0;
|
||||
color: #155724;
|
||||
}
|
||||
.feedback-preview {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-style: italic;
|
||||
color: #495057;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hi {{userName}},</p>
|
||||
|
||||
<div class="success-box">
|
||||
<div class="icon">✓</div>
|
||||
<p><strong>Thank You for Your Feedback!</strong></p>
|
||||
<p>We've received your feedback and our team will review it carefully.</p>
|
||||
</div>
|
||||
|
||||
<h2>Your Feedback</h2>
|
||||
<div class="feedback-preview">
|
||||
{{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>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>© {{year}} RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
159
backend/templates/emails/feedbackNotificationToAdmin.html
Normal file
159
backend/templates/emails/feedbackNotificationToAdmin.html
Normal file
@@ -0,0 +1,159 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Feedback Received - RentAll</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.alert-box {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.alert-box p {
|
||||
margin: 5px 0;
|
||||
color: #856404;
|
||||
}
|
||||
.content {
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.info-table th {
|
||||
background-color: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #dee2e6;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
width: 30%;
|
||||
}
|
||||
.info-table td {
|
||||
padding: 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
color: #212529;
|
||||
word-break: break-word;
|
||||
}
|
||||
.feedback-box {
|
||||
background-color: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #004085;
|
||||
}
|
||||
.feedback-box h3 {
|
||||
margin-top: 0;
|
||||
color: #004085;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
h2 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
}
|
||||
.timestamp {
|
||||
color: #6c757d;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">RentAll Admin</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="alert-box">
|
||||
<p><strong>New Feedback Received</strong></p>
|
||||
<p class="timestamp">Submitted at: {{submittedAt}}</p>
|
||||
</div>
|
||||
|
||||
<h2>User Information</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<td>{{userName}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<td>{{userEmail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>User ID</th>
|
||||
<td style="font-family: monospace; font-size: 12px;">{{userId}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="feedback-box">
|
||||
<h3>Feedback Content</h3>
|
||||
<p>{{feedbackText}}</p>
|
||||
</div>
|
||||
|
||||
<h2>Technical Context</h2>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Feedback ID</th>
|
||||
<td style="font-family: monospace; font-size: 12px;">{{feedbackId}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Page URL</th>
|
||||
<td>{{url}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>User Agent</th>
|
||||
<td style="font-size: 12px;">{{userAgent}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Submitted At</th>
|
||||
<td>{{submittedAt}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<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>© {{year}} RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@ import Navbar from './components/Navbar';
|
||||
import Footer from './components/Footer';
|
||||
import AuthModal from './components/AuthModal';
|
||||
import AlphaGate from './components/AlphaGate';
|
||||
import FeedbackButton from './components/FeedbackButton';
|
||||
import Home from './pages/Home';
|
||||
import GoogleCallback from './pages/GoogleCallback';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
@@ -32,7 +33,7 @@ import './App.css';
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5001';
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const { showAuthModal, authModalMode, closeAuthModal } = useAuth();
|
||||
const { showAuthModal, authModalMode, closeAuthModal, user } = useAuth();
|
||||
const [hasAlphaAccess, setHasAlphaAccess] = useState<boolean | null>(null);
|
||||
const [checkingAccess, setCheckingAccess] = useState(true);
|
||||
|
||||
@@ -192,6 +193,9 @@ const AppContent: React.FC = () => {
|
||||
onHide={closeAuthModal}
|
||||
initialMode={authModalMode}
|
||||
/>
|
||||
|
||||
{/* Show feedback button for authenticated users */}
|
||||
{user && <FeedbackButton />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,6 +51,9 @@ const DeclineRentalModal: React.FC<DeclineRentalModalProps> = ({
|
||||
// Call parent callback with updated rental data if we have it
|
||||
if (updatedRental) {
|
||||
onDeclineComplete(updatedRental);
|
||||
|
||||
// Notify Navbar to update pending count
|
||||
window.dispatchEvent(new CustomEvent("rentalStatusChanged"));
|
||||
}
|
||||
|
||||
// Reset all states when closing
|
||||
|
||||
75
frontend/src/components/FeedbackButton.tsx
Normal file
75
frontend/src/components/FeedbackButton.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState } from 'react';
|
||||
import FeedbackModal from './FeedbackModal';
|
||||
|
||||
const FeedbackButton: React.FC = () => {
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="feedback-tab"
|
||||
onClick={() => setShowPanel(true)}
|
||||
title="Send Feedback"
|
||||
aria-label="Send Feedback"
|
||||
>
|
||||
<div className="feedback-tab-text">FEEDBACK</div>
|
||||
</button>
|
||||
|
||||
<FeedbackModal show={showPanel} onClose={() => setShowPanel(false)} />
|
||||
|
||||
<style>{`
|
||||
.feedback-tab {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1000;
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
padding: 16px 10px;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
||||
.feedback-tab:hover {
|
||||
background-color: #0b5ed7;
|
||||
padding-right: 14px;
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.feedback-tab:active {
|
||||
background-color: #0a58ca;
|
||||
}
|
||||
|
||||
.feedback-tab-text {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.feedback-tab {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.feedback-tab-text {
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackButton;
|
||||
217
frontend/src/components/FeedbackModal.tsx
Normal file
217
frontend/src/components/FeedbackModal.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { feedbackAPI } from "../services/api";
|
||||
|
||||
interface FeedbackModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FeedbackModal: React.FC<FeedbackModalProps> = ({ show, onClose }) => {
|
||||
const [feedbackText, setFeedbackText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const MIN_LENGTH = 5;
|
||||
const MAX_LENGTH = 5000;
|
||||
const charCount = feedbackText.length;
|
||||
const isValid = charCount >= MIN_LENGTH && charCount <= MAX_LENGTH;
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
// Reset form when modal closes
|
||||
setTimeout(() => {
|
||||
setFeedbackText("");
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
}, 300);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isValid) {
|
||||
setError(
|
||||
`Feedback must be between ${MIN_LENGTH} and ${MAX_LENGTH} characters`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// Capture the current URL
|
||||
const currentUrl = window.location.href;
|
||||
|
||||
await feedbackAPI.submitFeedback({
|
||||
feedbackText: feedbackText.trim(),
|
||||
url: currentUrl,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.error ||
|
||||
"Failed to submit feedback. Please try again."
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`feedback-panel ${show ? "show" : ""}`}>
|
||||
<div className="feedback-panel-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">Share Feedback</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{success ? (
|
||||
<div className="alert alert-success" role="alert">
|
||||
<h6 className="alert-heading">Thank you!</h6>
|
||||
<p className="mb-0">
|
||||
Your feedback has been submitted successfully! We appreciate
|
||||
you making Community Rentals 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!
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<textarea
|
||||
id="feedbackText"
|
||||
className="form-control"
|
||||
rows={6}
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
disabled={loading}
|
||||
maxLength={MAX_LENGTH}
|
||||
required
|
||||
/>
|
||||
{charCount > 0 && charCount < MIN_LENGTH && (
|
||||
<div
|
||||
className="text-danger mt-2"
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
Minimum {MIN_LENGTH} characters required
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!success && (
|
||||
<div className="modal-footer" style={{ gap: "10px" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || !isValid}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner-border spinner-border-sm me-2"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Share"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.feedback-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: -400px;
|
||||
transform: translateY(-50%);
|
||||
height: auto;
|
||||
max-height: calc(100vh - 40px);
|
||||
width: 400px;
|
||||
background-color: white;
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px 0 0 8px;
|
||||
z-index: 1050;
|
||||
transition: right 0.3s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feedback-panel.show {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.feedback-panel-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feedback-panel .modal-header {
|
||||
border-bottom: none;
|
||||
padding: 1rem 1.5rem 0.5rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feedback-panel .modal-body {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.feedback-panel .modal-footer {
|
||||
border-top: none;
|
||||
padding: 0 1.5rem 1rem 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.feedback-panel {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackModal;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { rentalAPI } from "../services/api";
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const { user, logout, openAuthModal } = useAuth();
|
||||
@@ -9,6 +10,36 @@ const Navbar: React.FC = () => {
|
||||
search: "",
|
||||
location: "",
|
||||
});
|
||||
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
|
||||
|
||||
// Fetch pending rental requests count when user logs in
|
||||
useEffect(() => {
|
||||
const fetchPendingCount = async () => {
|
||||
if (user) {
|
||||
try {
|
||||
const response = await rentalAPI.getPendingRequestsCount();
|
||||
setPendingRequestsCount(response.data.count);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch pending requests count:", error);
|
||||
}
|
||||
} else {
|
||||
setPendingRequestsCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPendingCount();
|
||||
|
||||
// Listen for rental status changes to refresh count
|
||||
const handleRentalStatusChange = () => {
|
||||
fetchPendingCount();
|
||||
};
|
||||
|
||||
window.addEventListener("rentalStatusChanged", handleRentalStatusChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("rentalStatusChanged", handleRentalStatusChange);
|
||||
};
|
||||
}, [user]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -123,8 +154,35 @@ const Navbar: React.FC = () => {
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span style={{ display: "flex", alignItems: "center", position: "relative" }}>
|
||||
{pendingRequestsCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "-0.9em",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
backgroundColor: "#dc3545",
|
||||
color: "white",
|
||||
borderRadius: "50%",
|
||||
width: "1.5em",
|
||||
height: "1.5em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.85em",
|
||||
fontWeight: "bold",
|
||||
border: "2px solid white",
|
||||
opacity: 1,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{pendingRequestsCount}
|
||||
</span>
|
||||
)}
|
||||
<i className="bi bi-person-circle me-1"></i>
|
||||
{user.firstName}
|
||||
</span>
|
||||
</a>
|
||||
<ul
|
||||
className="dropdown-menu"
|
||||
@@ -144,6 +202,11 @@ const Navbar: React.FC = () => {
|
||||
<li>
|
||||
<Link className="dropdown-item" to="/my-listings">
|
||||
<i className="bi bi-list-ul me-2"></i>Owning
|
||||
{pendingRequestsCount > 0 && (
|
||||
<span className="badge bg-danger rounded-pill ms-2">
|
||||
{pendingRequestsCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -164,6 +164,9 @@ const MyListings: React.FC = () => {
|
||||
|
||||
fetchOwnerRentals();
|
||||
fetchAvailableChecks(); // Refresh available checks after rental confirmation
|
||||
|
||||
// Notify Navbar to update pending count
|
||||
window.dispatchEvent(new CustomEvent("rentalStatusChanged"));
|
||||
} catch (err: any) {
|
||||
console.error("Failed to accept rental request:", err);
|
||||
|
||||
|
||||
@@ -205,6 +205,7 @@ export const rentalAPI = {
|
||||
createRental: (data: any) => api.post("/rentals", data),
|
||||
getMyRentals: () => api.get("/rentals/my-rentals"),
|
||||
getMyListings: () => api.get("/rentals/my-listings"),
|
||||
getPendingRequestsCount: () => api.get("/rentals/pending-requests-count"),
|
||||
updateRentalStatus: (id: string, status: string) =>
|
||||
api.put(`/rentals/${id}/status`, { status }),
|
||||
markAsCompleted: (id: string) => api.post(`/rentals/${id}/mark-completed`),
|
||||
@@ -292,4 +293,9 @@ export const conditionCheckAPI = {
|
||||
getAvailableChecks: () => api.get("/condition-checks"),
|
||||
};
|
||||
|
||||
export const feedbackAPI = {
|
||||
submitFeedback: (data: { feedbackText: string; url?: string }) =>
|
||||
api.post("/feedback", data),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -293,3 +293,13 @@ export interface RefundPreview {
|
||||
reason: string;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
id: string;
|
||||
userId: string;
|
||||
feedbackText: string;
|
||||
userAgent?: string;
|
||||
url?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user