Compare commits
2 Commits
83872fe039
...
b2f18d77f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2f18d77f6 | ||
|
|
88c831419c |
@@ -141,6 +141,26 @@ const Item = sequelize.define("Item", {
|
|||||||
key: "id",
|
key: "id",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
isDeleted: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
deletedBy: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
references: {
|
||||||
|
model: "Users",
|
||||||
|
key: "id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deletedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
deletionReason: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = Item;
|
module.exports = Item;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const Feedback = require("./Feedback");
|
|||||||
|
|
||||||
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
||||||
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||||
|
Item.belongsTo(User, { as: "deleter", foreignKey: "deletedBy" });
|
||||||
|
|
||||||
User.hasMany(Rental, { as: "rentalsAsRenter", foreignKey: "renterId" });
|
User.hasMany(Rental, { as: "rentalsAsRenter", foreignKey: "renterId" });
|
||||||
User.hasMany(Rental, { as: "rentalsAsOwner", foreignKey: "ownerId" });
|
User.hasMany(Rental, { as: "rentalsAsOwner", foreignKey: "ownerId" });
|
||||||
|
|||||||
@@ -395,7 +395,21 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
|||||||
attributes: ['itemRequestNotificationRadius']
|
attributes: ['itemRequestNotificationRadius']
|
||||||
});
|
});
|
||||||
|
|
||||||
const userPreferredRadius = userProfile?.itemRequestNotificationRadius || 10;
|
const userPreferredRadius = userProfile?.itemRequestNotificationRadius;
|
||||||
|
|
||||||
|
// Skip if user has disabled notifications (null)
|
||||||
|
if (userPreferredRadius === null || userPreferredRadius === undefined) {
|
||||||
|
logger.info("User has disabled item request notifications", {
|
||||||
|
postId: post.id,
|
||||||
|
userId: user.id,
|
||||||
|
userDistance: user.distance
|
||||||
|
});
|
||||||
|
usersSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 10 miles if somehow not set
|
||||||
|
const effectiveRadius = userPreferredRadius || 10;
|
||||||
|
|
||||||
logger.info("Checking user notification eligibility", {
|
logger.info("Checking user notification eligibility", {
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
@@ -404,12 +418,12 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
|||||||
userCoordinates: { lat: user.latitude, lng: user.longitude },
|
userCoordinates: { lat: user.latitude, lng: user.longitude },
|
||||||
postCoordinates: { lat: latitude, lng: longitude },
|
postCoordinates: { lat: latitude, lng: longitude },
|
||||||
userDistance: user.distance,
|
userDistance: user.distance,
|
||||||
userPreferredRadius,
|
userPreferredRadius: effectiveRadius,
|
||||||
willNotify: parseFloat(user.distance) <= userPreferredRadius
|
willNotify: parseFloat(user.distance) <= effectiveRadius
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only notify if within user's preferred radius
|
// Only notify if within user's preferred radius
|
||||||
if (parseFloat(user.distance) <= userPreferredRadius) {
|
if (parseFloat(user.distance) <= effectiveRadius) {
|
||||||
try {
|
try {
|
||||||
await emailServices.forum.sendItemRequestNotification(
|
await emailServices.forum.sendItemRequestNotification(
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { Op } = require("sequelize");
|
const { Op } = require("sequelize");
|
||||||
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
|
const { Item, User, Rental } = require("../models"); // Import from models/index.js to get models with associations
|
||||||
const { authenticateToken, requireVerifiedEmail } = require("../middleware/auth");
|
const { authenticateToken, requireVerifiedEmail, requireAdmin, optionalAuth } = require("../middleware/auth");
|
||||||
const logger = require("../utils/logger");
|
const logger = require("../utils/logger");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -17,7 +17,9 @@ router.get("/", async (req, res) => {
|
|||||||
limit = 20,
|
limit = 20,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const where = {};
|
const where = {
|
||||||
|
isDeleted: false // Always exclude soft-deleted items from public browse
|
||||||
|
};
|
||||||
|
|
||||||
if (minPrice || maxPrice) {
|
if (minPrice || maxPrice) {
|
||||||
where.pricePerDay = {};
|
where.pricePerDay = {};
|
||||||
@@ -97,6 +99,7 @@ router.get("/recommendations", authenticateToken, async (req, res) => {
|
|||||||
const recommendations = await Item.findAll({
|
const recommendations = await Item.findAll({
|
||||||
where: {
|
where: {
|
||||||
availability: true,
|
availability: true,
|
||||||
|
isDeleted: false,
|
||||||
},
|
},
|
||||||
limit: 10,
|
limit: 10,
|
||||||
order: [["createdAt", "DESC"]],
|
order: [["createdAt", "DESC"]],
|
||||||
@@ -170,7 +173,7 @@ router.get('/:id/reviews', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/:id", async (req, res) => {
|
router.get("/:id", optionalAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const item = await Item.findByPk(req.params.id, {
|
const item = await Item.findByPk(req.params.id, {
|
||||||
include: [
|
include: [
|
||||||
@@ -179,6 +182,11 @@ router.get("/:id", async (req, res) => {
|
|||||||
as: "owner",
|
as: "owner",
|
||||||
attributes: ["id", "username", "firstName", "lastName"],
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "deleter",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +194,15 @@ router.get("/:id", async (req, res) => {
|
|||||||
return res.status(404).json({ error: "Item not found" });
|
return res.status(404).json({ error: "Item not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if item is deleted - only allow admins to view
|
||||||
|
if (item.isDeleted) {
|
||||||
|
const isAdmin = req.user?.role === 'admin';
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return res.status(404).json({ error: "Item not found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Round coordinates to 2 decimal places for map display while keeping precise values in database
|
// Round coordinates to 2 decimal places for map display while keeping precise values in database
|
||||||
const itemResponse = item.toJSON();
|
const itemResponse = item.toJSON();
|
||||||
if (itemResponse.latitude !== null && itemResponse.latitude !== undefined) {
|
if (itemResponse.latitude !== null && itemResponse.latitude !== undefined) {
|
||||||
@@ -347,4 +364,156 @@ router.delete("/:id", authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
router.delete("/admin/:id", authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { reason } = req.body;
|
||||||
|
|
||||||
|
if (!reason || !reason.trim()) {
|
||||||
|
return res.status(400).json({ error: "Deletion reason is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await Item.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName", "email"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: "Item not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isDeleted) {
|
||||||
|
return res.status(400).json({ error: "Item is already deleted" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for active or upcoming rentals
|
||||||
|
const activeRentals = await Rental.count({
|
||||||
|
where: {
|
||||||
|
itemId: req.params.id,
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['pending', 'confirmed', 'active']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeRentals > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Cannot delete item with active or upcoming rentals",
|
||||||
|
code: "ACTIVE_RENTALS_EXIST",
|
||||||
|
activeRentalsCount: activeRentals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete the item
|
||||||
|
await item.update({
|
||||||
|
isDeleted: true,
|
||||||
|
deletedBy: req.user.id,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
deletionReason: reason.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedItem = await Item.findByPk(item.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "deleter",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notification to owner
|
||||||
|
try {
|
||||||
|
const emailServices = require("../services/email");
|
||||||
|
await emailServices.userEngagement.sendItemDeletionNotificationToOwner(
|
||||||
|
item.owner,
|
||||||
|
item,
|
||||||
|
reason.trim()
|
||||||
|
);
|
||||||
|
console.log(`Item deletion notification email sent to owner ${item.ownerId}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
// Log but don't fail the deletion
|
||||||
|
console.error('Failed to send item deletion notification email:', emailError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Item soft deleted by admin", {
|
||||||
|
itemId: req.params.id,
|
||||||
|
deletedBy: req.user.id,
|
||||||
|
ownerId: item.ownerId,
|
||||||
|
reason: reason.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedItem);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin item soft delete failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
itemId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const item = await Item.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return res.status(404).json({ error: "Item not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.isDeleted) {
|
||||||
|
return res.status(400).json({ error: "Item is not deleted" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the item
|
||||||
|
await item.update({
|
||||||
|
isDeleted: false,
|
||||||
|
deletedBy: null,
|
||||||
|
deletedAt: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedItem = await Item.findByPk(item.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: User,
|
||||||
|
as: "owner",
|
||||||
|
attributes: ["id", "username", "firstName", "lastName"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Item restored by admin", {
|
||||||
|
itemId: req.params.id,
|
||||||
|
restoredBy: req.user.id,
|
||||||
|
ownerId: item.ownerId
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updatedItem);
|
||||||
|
} catch (error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Admin item restore failed", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
itemId: req.params.id,
|
||||||
|
adminId: req.user.id
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class TemplateManager {
|
|||||||
"rentalCompletionCongratsToOwner.html",
|
"rentalCompletionCongratsToOwner.html",
|
||||||
"payoutReceivedToOwner.html",
|
"payoutReceivedToOwner.html",
|
||||||
"firstListingCelebrationToOwner.html",
|
"firstListingCelebrationToOwner.html",
|
||||||
|
"itemDeletionToOwner.html",
|
||||||
"alphaInvitationToUser.html",
|
"alphaInvitationToUser.html",
|
||||||
"feedbackConfirmationToUser.html",
|
"feedbackConfirmationToUser.html",
|
||||||
"feedbackNotificationToAdmin.html",
|
"feedbackNotificationToAdmin.html",
|
||||||
|
|||||||
@@ -72,6 +72,52 @@ class UserEngagementEmailService {
|
|||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send item deletion notification email to owner
|
||||||
|
* @param {Object} owner - Owner user object
|
||||||
|
* @param {string} owner.firstName - Owner's first name
|
||||||
|
* @param {string} owner.email - Owner's email address
|
||||||
|
* @param {Object} item - Item object
|
||||||
|
* @param {number} item.id - Item ID
|
||||||
|
* @param {string} item.name - Item name
|
||||||
|
* @param {string} deletionReason - Reason for deletion
|
||||||
|
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
|
||||||
|
*/
|
||||||
|
async sendItemDeletionNotificationToOwner(owner, item, deletionReason) {
|
||||||
|
if (!this.initialized) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
|
const supportEmail = process.env.SUPPORT_EMAIL;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
ownerName: owner.firstName || "there",
|
||||||
|
itemName: item.name,
|
||||||
|
deletionReason,
|
||||||
|
supportEmail,
|
||||||
|
dashboardUrl: `${frontendUrl}/owning`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const htmlContent = await this.templateManager.renderTemplate(
|
||||||
|
"itemDeletionToOwner",
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
|
||||||
|
const subject = `Important: Your listing "${item.name}" has been removed`;
|
||||||
|
|
||||||
|
return await this.emailClient.sendEmail(
|
||||||
|
owner.email,
|
||||||
|
subject,
|
||||||
|
htmlContent
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send item deletion notification email:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = UserEngagementEmailService;
|
module.exports = UserEngagementEmailService;
|
||||||
|
|||||||
305
backend/templates/emails/itemDeletionToOwner.html
Normal file
305
backend/templates/emails/itemDeletionToOwner.html
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Your 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header - 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 {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header, .content, .footer {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-highlight .item-name {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">RentAll</div>
|
||||||
|
<div class="tagline">⚠️ 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 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>
|
||||||
|
|
||||||
|
<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>© 2024 RentAll. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,11 @@ interface ConfirmationModalProps {
|
|||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
confirmButtonClass?: string;
|
confirmButtonClass?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
showReasonInput?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
onReasonChange?: (reason: string) => void;
|
||||||
|
reasonPlaceholder?: string;
|
||||||
|
reasonRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
||||||
@@ -21,40 +26,62 @@ const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
|
|||||||
confirmText = 'Confirm',
|
confirmText = 'Confirm',
|
||||||
cancelText = 'Cancel',
|
cancelText = 'Cancel',
|
||||||
confirmButtonClass = 'btn-danger',
|
confirmButtonClass = 'btn-danger',
|
||||||
loading = false
|
loading = false,
|
||||||
|
showReasonInput = false,
|
||||||
|
reason = '',
|
||||||
|
onReasonChange,
|
||||||
|
reasonPlaceholder = 'Enter reason...',
|
||||||
|
reasonRequired = false
|
||||||
}) => {
|
}) => {
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
|
const isConfirmDisabled = loading || (reasonRequired && showReasonInput && !reason.trim());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
<div className="modal d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
<div className="modal-dialog modal-dialog-centered">
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h5 className="modal-title">{title}</h5>
|
<h5 className="modal-title">{title}</h5>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-close"
|
className="btn-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
|
{showReasonInput && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="form-label">
|
||||||
|
Reason {reasonRequired && <span className="text-danger">*</span>}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => onReasonChange?.(e.target.value)}
|
||||||
|
placeholder={reasonPlaceholder}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn ${confirmButtonClass}`}
|
className={`btn ${confirmButtonClass}`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={loading}
|
disabled={isConfirmDisabled}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from "../contexts/AuthContext";
|
|||||||
import { itemAPI, rentalAPI } from "../services/api";
|
import { itemAPI, rentalAPI } from "../services/api";
|
||||||
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||||
import ItemReviews from "../components/ItemReviews";
|
import ItemReviews from "../components/ItemReviews";
|
||||||
|
import ConfirmationModal from "../components/ConfirmationModal";
|
||||||
|
|
||||||
const ItemDetail: React.FC = () => {
|
const ItemDetail: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -24,6 +25,13 @@ const ItemDetail: React.FC = () => {
|
|||||||
const [totalCost, setTotalCost] = useState(0);
|
const [totalCost, setTotalCost] = useState(0);
|
||||||
const [costLoading, setCostLoading] = useState(false);
|
const [costLoading, setCostLoading] = useState(false);
|
||||||
const [costError, setCostError] = useState<string | null>(null);
|
const [costError, setCostError] = useState<string | null>(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<
|
||||||
|
"delete" | "restore" | null
|
||||||
|
>(null);
|
||||||
|
const [deletionReason, setDeletionReason] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchItem();
|
fetchItem();
|
||||||
@@ -68,6 +76,50 @@ const ItemDetail: React.FC = () => {
|
|||||||
navigate(`/items/${id}/edit`);
|
navigate(`/items/${id}/edit`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdminSoftDelete = () => {
|
||||||
|
setConfirmAction("delete");
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminRestore = () => {
|
||||||
|
setConfirmAction("restore");
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAction = async () => {
|
||||||
|
try {
|
||||||
|
setDeleteLoading(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
|
||||||
|
if (confirmAction === "delete") {
|
||||||
|
await itemAPI.adminSoftDeleteItem(id!, deletionReason);
|
||||||
|
} else if (confirmAction === "restore") {
|
||||||
|
await itemAPI.adminRestoreItem(id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchItem(); // Refresh the item to show updated status
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setConfirmAction(null);
|
||||||
|
setDeletionReason("");
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.error || `Failed to ${confirmAction} item`;
|
||||||
|
setDeleteError(errorMessage);
|
||||||
|
console.error(`Admin ${confirmAction} failed:`, err);
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setConfirmAction(null);
|
||||||
|
setDeletionReason("");
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelConfirm = () => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setConfirmAction(null);
|
||||||
|
setDeletionReason("");
|
||||||
|
};
|
||||||
|
|
||||||
const handleRent = () => {
|
const handleRent = () => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
startDate: rentalDates.startDate,
|
startDate: rentalDates.startDate,
|
||||||
@@ -260,17 +312,101 @@ const ItemDetail: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = user?.id === item.ownerId;
|
const isOwner = user?.id === item.ownerId;
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mt-5">
|
<div className="container mt-5">
|
||||||
<div className="row justify-content-center">
|
<div className="row justify-content-center">
|
||||||
<div className="col-md-10">
|
<div className="col-md-10">
|
||||||
{isOwner && (
|
{/* Deleted Status Indicator for Admins */}
|
||||||
<div className="d-flex justify-content-end mb-3">
|
{item.isDeleted && isAdmin && (
|
||||||
<button className="btn btn-outline-primary" onClick={handleEdit}>
|
<div className="alert alert-warning mb-3" role="alert">
|
||||||
<i className="bi bi-pencil me-2"></i>
|
<i className="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
Edit Listing
|
<strong>Item Soft Deleted</strong> - This item is hidden from
|
||||||
</button>
|
public listings.
|
||||||
|
{item.deleter && (
|
||||||
|
<span className="ms-2">
|
||||||
|
Deleted by {item.deleter.firstName} {item.deleter.lastName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.deletedAt && (
|
||||||
|
<span className="ms-2">
|
||||||
|
on {new Date(item.deletedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.deletionReason && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<strong>Reason:</strong> {item.deletionReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Error Alert */}
|
||||||
|
{deleteError && (
|
||||||
|
<div className="alert alert-danger mb-3" role="alert">
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons (Owner Edit + Admin Soft Delete/Restore) */}
|
||||||
|
{(isOwner || isAdmin) && (
|
||||||
|
<div className="d-flex justify-content-end gap-2 mb-3">
|
||||||
|
{isOwner && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleEdit}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil me-2"></i>
|
||||||
|
Edit Listing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && !item.isDeleted && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger"
|
||||||
|
onClick={handleAdminSoftDelete}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
>
|
||||||
|
{deleteLoading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="spinner-border spinner-border-sm me-2"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-trash me-2"></i>
|
||||||
|
Delete
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && item.isDeleted && (
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-success"
|
||||||
|
onClick={handleAdminRestore}
|
||||||
|
disabled={deleteLoading}
|
||||||
|
>
|
||||||
|
{deleteLoading ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="spinner-border spinner-border-sm me-2"
|
||||||
|
role="status"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
Restoring...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="bi bi-arrow-counterclockwise me-2"></i>
|
||||||
|
Restore
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -582,8 +718,13 @@ const ItemDetail: React.FC = () => {
|
|||||||
{rentalDates.startDate && rentalDates.endDate && (
|
{rentalDates.startDate && rentalDates.endDate && (
|
||||||
<div className="mb-3 p-2 bg-light rounded text-center">
|
<div className="mb-3 p-2 bg-light rounded text-center">
|
||||||
{costLoading ? (
|
{costLoading ? (
|
||||||
<div className="spinner-border spinner-border-sm" role="status">
|
<div
|
||||||
<span className="visually-hidden">Calculating...</span>
|
className="spinner-border spinner-border-sm"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="visually-hidden">
|
||||||
|
Calculating...
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : costError ? (
|
) : costError ? (
|
||||||
<small className="text-danger">{costError}</small>
|
<small className="text-danger">{costError}</small>
|
||||||
@@ -627,6 +768,32 @@ const ItemDetail: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
<ConfirmationModal
|
||||||
|
show={showConfirmModal}
|
||||||
|
onClose={handleCancelConfirm}
|
||||||
|
onConfirm={handleConfirmAction}
|
||||||
|
title={
|
||||||
|
confirmAction === "delete" ? "Confirm Delete" : "Confirm Restore"
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
confirmAction === "delete"
|
||||||
|
? "Are you sure you want to delete this item? It will be hidden from public listings."
|
||||||
|
: "Are you sure you want to restore this item? It will be visible to the public again."
|
||||||
|
}
|
||||||
|
confirmText={confirmAction === "delete" ? "Delete" : "Restore"}
|
||||||
|
cancelText="Cancel"
|
||||||
|
confirmButtonClass={
|
||||||
|
confirmAction === "delete" ? "btn-danger" : "btn-success"
|
||||||
|
}
|
||||||
|
loading={deleteLoading}
|
||||||
|
showReasonInput={confirmAction === "delete"}
|
||||||
|
reason={deletionReason}
|
||||||
|
onReasonChange={setDeletionReason}
|
||||||
|
reasonPlaceholder="Enter reason for deletion (e.g., policy violation, inappropriate content)"
|
||||||
|
reasonRequired={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ const Owning: React.FC = () => {
|
|||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-2">
|
<div className="mb-2 d-flex gap-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
className={`badge ${
|
className={`badge ${
|
||||||
item.availability ? "bg-success" : "bg-secondary"
|
item.availability ? "bg-success" : "bg-secondary"
|
||||||
@@ -541,6 +541,12 @@ const Owning: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{item.availability ? "Available" : "Not Available"}
|
{item.availability ? "Available" : "Not Available"}
|
||||||
</span>
|
</span>
|
||||||
|
{item.isDeleted && (
|
||||||
|
<span className="badge bg-danger">
|
||||||
|
<i className="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
Deleted by Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
} from "../services/geocodingService";
|
} from "../services/geocodingService";
|
||||||
import AddressAutocomplete from "../components/AddressAutocomplete";
|
import AddressAutocomplete from "../components/AddressAutocomplete";
|
||||||
import { PlaceDetails } from "../services/placesService";
|
import { PlaceDetails } from "../services/placesService";
|
||||||
import { useAddressAutocomplete, usStates } from "../hooks/useAddressAutocomplete";
|
import {
|
||||||
|
useAddressAutocomplete,
|
||||||
|
usStates,
|
||||||
|
} from "../hooks/useAddressAutocomplete";
|
||||||
|
|
||||||
const Profile: React.FC = () => {
|
const Profile: React.FC = () => {
|
||||||
const { user, updateUser, logout } = useAuth();
|
const { user, updateUser, logout } = useAuth();
|
||||||
@@ -25,7 +28,20 @@ const Profile: React.FC = () => {
|
|||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
const [activeSection, setActiveSection] = useState<string>("overview");
|
const [activeSection, setActiveSection] = useState<string>("overview");
|
||||||
const [profileData, setProfileData] = useState<User | null>(null);
|
const [profileData, setProfileData] = useState<User | null>(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState<{
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address1: string;
|
||||||
|
address2: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
country: string;
|
||||||
|
profileImage: string;
|
||||||
|
itemRequestNotificationRadius: number | null;
|
||||||
|
}>({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
@@ -141,7 +157,8 @@ const Profile: React.FC = () => {
|
|||||||
zipCode: response.data.zipCode || "",
|
zipCode: response.data.zipCode || "",
|
||||||
country: response.data.country || "",
|
country: response.data.country || "",
|
||||||
profileImage: response.data.profileImage || "",
|
profileImage: response.data.profileImage || "",
|
||||||
itemRequestNotificationRadius: response.data.itemRequestNotificationRadius || 10,
|
itemRequestNotificationRadius:
|
||||||
|
response.data.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
if (response.data.profileImage) {
|
if (response.data.profileImage) {
|
||||||
setImagePreview(getImageUrl(response.data.profileImage));
|
setImagePreview(getImageUrl(response.data.profileImage));
|
||||||
@@ -264,7 +281,9 @@ const Profile: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
e: React.ChangeEvent<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
@@ -361,7 +380,8 @@ const Profile: React.FC = () => {
|
|||||||
zipCode: profileData.zipCode || "",
|
zipCode: profileData.zipCode || "",
|
||||||
country: profileData.country || "",
|
country: profileData.country || "",
|
||||||
profileImage: profileData.profileImage || "",
|
profileImage: profileData.profileImage || "",
|
||||||
itemRequestNotificationRadius: profileData.itemRequestNotificationRadius || 10,
|
itemRequestNotificationRadius:
|
||||||
|
profileData.itemRequestNotificationRadius || 10,
|
||||||
});
|
});
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
|
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
|
||||||
@@ -415,7 +435,10 @@ const Profile: React.FC = () => {
|
|||||||
setSuccess("Notification preferences saved successfully");
|
setSuccess("Notification preferences saved successfully");
|
||||||
setTimeout(() => setSuccess(null), 3000);
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Notification preferences update error:", err.response?.data);
|
console.error(
|
||||||
|
"Notification preferences update error:",
|
||||||
|
err.response?.data
|
||||||
|
);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
err.response?.data?.message ||
|
err.response?.data?.message ||
|
||||||
@@ -428,7 +451,10 @@ const Profile: React.FC = () => {
|
|||||||
e: React.ChangeEvent<HTMLSelectElement>
|
e: React.ChangeEvent<HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
const { value } = e.target;
|
const { value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, itemRequestNotificationRadius: parseInt(value) }));
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
itemRequestNotificationRadius: parseInt(value),
|
||||||
|
}));
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -438,7 +464,10 @@ const Profile: React.FC = () => {
|
|||||||
setProfileData(response.data);
|
setProfileData(response.data);
|
||||||
updateUser(response.data);
|
updateUser(response.data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Notification preferences update error:", err.response?.data);
|
console.error(
|
||||||
|
"Notification preferences update error:",
|
||||||
|
err.response?.data
|
||||||
|
);
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
err.response?.data?.error ||
|
err.response?.data?.error ||
|
||||||
err.response?.data?.message ||
|
err.response?.data?.message ||
|
||||||
@@ -560,8 +589,8 @@ const Profile: React.FC = () => {
|
|||||||
...addressFormData,
|
...addressFormData,
|
||||||
...(coordinates && {
|
...(coordinates && {
|
||||||
latitude: coordinates.latitude,
|
latitude: coordinates.latitude,
|
||||||
longitude: coordinates.longitude
|
longitude: coordinates.longitude,
|
||||||
})
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -614,17 +643,20 @@ const Profile: React.FC = () => {
|
|||||||
const { parsePlace } = useAddressAutocomplete();
|
const { parsePlace } = useAddressAutocomplete();
|
||||||
|
|
||||||
// Handle place selection from autocomplete
|
// Handle place selection from autocomplete
|
||||||
const handlePlaceSelect = useCallback((place: PlaceDetails) => {
|
const handlePlaceSelect = useCallback(
|
||||||
const parsedAddress = parsePlace(place);
|
(place: PlaceDetails) => {
|
||||||
if (parsedAddress) {
|
const parsedAddress = parsePlace(place);
|
||||||
setAddressFormData((prev) => ({
|
if (parsedAddress) {
|
||||||
...prev,
|
setAddressFormData((prev) => ({
|
||||||
...parsedAddress,
|
...prev,
|
||||||
}));
|
...parsedAddress,
|
||||||
setAddressGeocodeSuccess(true);
|
}));
|
||||||
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
setAddressGeocodeSuccess(true);
|
||||||
}
|
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
||||||
}, [parsePlace]);
|
}
|
||||||
|
},
|
||||||
|
[parsePlace]
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -781,335 +813,344 @@ const Profile: React.FC = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-link text-primary p-0 ms-2"
|
className="btn btn-link text-primary p-0 ms-2"
|
||||||
onClick={() => setShowPersonalInfo(!showPersonalInfo)}
|
onClick={() => setShowPersonalInfo(!showPersonalInfo)}
|
||||||
style={{ textDecoration: 'none' }}
|
style={{ textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
<i className={`bi ${showPersonalInfo ? 'bi-eye' : 'bi-eye-slash'} fs-5`}></i>
|
<i
|
||||||
|
className={`bi ${
|
||||||
|
showPersonalInfo ? "bi-eye" : "bi-eye-slash"
|
||||||
|
} fs-5`}
|
||||||
|
></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showPersonalInfo && (
|
{showPersonalInfo && (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="row mb-3">
|
<div className="row mb-3">
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<label htmlFor="firstName" className="form-label">
|
<label htmlFor="firstName" className="form-label">
|
||||||
First Name
|
First Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={formData.firstName}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!editing}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="lastName" className="form-label">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={formData.lastName}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!editing}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label htmlFor="email" className="form-label">
|
||||||
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="email"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
id="firstName"
|
id="email"
|
||||||
name="firstName"
|
name="email"
|
||||||
value={formData.firstName}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
disabled={!editing}
|
disabled={!editing}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-6">
|
|
||||||
<label htmlFor="lastName" className="form-label">
|
<div className="mb-3">
|
||||||
Last Name
|
<label htmlFor="phone" className="form-label">
|
||||||
|
Phone Number
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="tel"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
id="lastName"
|
id="phone"
|
||||||
name="lastName"
|
name="phone"
|
||||||
value={formData.lastName}
|
value={formData.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
placeholder="(123) 456-7890"
|
||||||
disabled={!editing}
|
disabled={!editing}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<hr className="my-4" />
|
||||||
<label htmlFor="email" className="form-label">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
className="form-control"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
disabled={!editing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
{/* Saved Addresses Section */}
|
||||||
<label htmlFor="phone" className="form-label">
|
<div className="mb-3">
|
||||||
Phone Number
|
<label className="form-label">Saved Addresses</label>
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
className="form-control"
|
|
||||||
id="phone"
|
|
||||||
name="phone"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="(123) 456-7890"
|
|
||||||
disabled={!editing}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
{addressesLoading ? (
|
||||||
|
<div className="text-center py-3">
|
||||||
{/* Saved Addresses Section */}
|
<div
|
||||||
<div className="mb-3">
|
className="spinner-border spinner-border-sm"
|
||||||
<label className="form-label">Saved Addresses</label>
|
role="status"
|
||||||
|
>
|
||||||
{addressesLoading ? (
|
<span className="visually-hidden">
|
||||||
<div className="text-center py-3">
|
Loading addresses...
|
||||||
<div
|
</span>
|
||||||
className="spinner-border spinner-border-sm"
|
</div>
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span className="visually-hidden">
|
|
||||||
Loading addresses...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{userAddresses.length === 0 && !showAddressForm ? (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<p className="text-muted mb-2">
|
||||||
|
No saved addresses yet
|
||||||
|
</p>
|
||||||
|
<small className="text-muted">
|
||||||
|
Add an address or create your first listing to
|
||||||
|
save one automatically
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{userAddresses.length > 0 &&
|
||||||
|
!showAddressForm && (
|
||||||
|
<>
|
||||||
|
<div className="list-group list-group-flush mb-3">
|
||||||
|
{userAddresses.map((address) => (
|
||||||
|
<div
|
||||||
|
key={address.id}
|
||||||
|
className="list-group-item d-flex justify-content-between align-items-start"
|
||||||
|
>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<div className="fw-medium">
|
||||||
|
{formatAddressDisplay(address)}
|
||||||
|
</div>
|
||||||
|
{address.address2 && (
|
||||||
|
<small className="text-muted">
|
||||||
|
{address.address2}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="btn-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleEditAddress(address)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteAddress(
|
||||||
|
address.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleAddAddress}
|
||||||
|
>
|
||||||
|
Add New Address
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show Add New Address button even when no addresses exist */}
|
||||||
|
{userAddresses.length === 0 && !showAddressForm && (
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
onClick={handleAddAddress}
|
||||||
|
>
|
||||||
|
Add New Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address Form */}
|
||||||
|
{showAddressForm && (
|
||||||
|
<div>
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label
|
||||||
|
htmlFor="addressFormAddress1"
|
||||||
|
className="form-label"
|
||||||
|
>
|
||||||
|
Address Line 1 *
|
||||||
|
</label>
|
||||||
|
<AddressAutocomplete
|
||||||
|
id="addressFormAddress1"
|
||||||
|
name="address1"
|
||||||
|
value={addressFormData.address1}
|
||||||
|
onChange={(value) => {
|
||||||
|
const syntheticEvent = {
|
||||||
|
target: {
|
||||||
|
name: "address1",
|
||||||
|
value,
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
} as React.ChangeEvent<HTMLInputElement>;
|
||||||
|
handleAddressFormChange(syntheticEvent);
|
||||||
|
}}
|
||||||
|
onPlaceSelect={handlePlaceSelect}
|
||||||
|
placeholder="Start typing an address..."
|
||||||
|
className="form-control"
|
||||||
|
required
|
||||||
|
countryRestriction="us"
|
||||||
|
types={["address"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label
|
||||||
|
htmlFor="addressFormAddress2"
|
||||||
|
className="form-label"
|
||||||
|
>
|
||||||
|
Address Line 2
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="addressFormAddress2"
|
||||||
|
name="address2"
|
||||||
|
value={addressFormData.address2}
|
||||||
|
onChange={handleAddressFormChange}
|
||||||
|
placeholder="Apt, Suite, Unit, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label
|
||||||
|
htmlFor="addressFormCity"
|
||||||
|
className="form-label"
|
||||||
|
>
|
||||||
|
City *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="addressFormCity"
|
||||||
|
name="city"
|
||||||
|
value={addressFormData.city}
|
||||||
|
onChange={handleAddressFormChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label
|
||||||
|
htmlFor="addressFormState"
|
||||||
|
className="form-label"
|
||||||
|
>
|
||||||
|
State *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="addressFormState"
|
||||||
|
name="state"
|
||||||
|
value={addressFormData.state}
|
||||||
|
onChange={handleAddressFormChange}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select State</option>
|
||||||
|
{usStates.map((state) => (
|
||||||
|
<option key={state} value={state}>
|
||||||
|
{state}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<label
|
||||||
|
htmlFor="addressFormZipCode"
|
||||||
|
className="form-label"
|
||||||
|
>
|
||||||
|
ZIP Code *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
id="addressFormZipCode"
|
||||||
|
name="zipCode"
|
||||||
|
value={addressFormData.zipCode}
|
||||||
|
onChange={handleAddressFormChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSaveAddress(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="12345"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSaveAddress}
|
||||||
|
>
|
||||||
|
{editingAddressId
|
||||||
|
? "Update Address"
|
||||||
|
: "Save Address"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleCancelAddressForm}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
{userAddresses.length === 0 && !showAddressForm ? (
|
|
||||||
<div className="text-center py-3">
|
|
||||||
<p className="text-muted mb-2">No saved addresses yet</p>
|
|
||||||
<small className="text-muted">
|
|
||||||
Add an address or create your first listing to save
|
|
||||||
one automatically
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{userAddresses.length > 0 && !showAddressForm && (
|
|
||||||
<>
|
|
||||||
<div className="list-group list-group-flush mb-3">
|
|
||||||
{userAddresses.map((address) => (
|
|
||||||
<div
|
|
||||||
key={address.id}
|
|
||||||
className="list-group-item d-flex justify-content-between align-items-start"
|
|
||||||
>
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<div className="fw-medium">
|
|
||||||
{formatAddressDisplay(address)}
|
|
||||||
</div>
|
|
||||||
{address.address2 && (
|
|
||||||
<small className="text-muted">
|
|
||||||
{address.address2}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="btn-group">
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-secondary btn-sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleEditAddress(address)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<i className="bi bi-pencil"></i>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-danger btn-sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleDeleteAddress(address.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<i className="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={handleAddAddress}
|
|
||||||
>
|
|
||||||
Add New Address
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show Add New Address button even when no addresses exist */}
|
|
||||||
{userAddresses.length === 0 && !showAddressForm && (
|
|
||||||
<div className="text-center">
|
|
||||||
<button
|
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={handleAddAddress}
|
|
||||||
>
|
|
||||||
Add New Address
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Address Form */}
|
|
||||||
{showAddressForm && (
|
|
||||||
<div>
|
|
||||||
<div className="row mb-3">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label
|
|
||||||
htmlFor="addressFormAddress1"
|
|
||||||
className="form-label"
|
|
||||||
>
|
|
||||||
Address Line 1 *
|
|
||||||
</label>
|
|
||||||
<AddressAutocomplete
|
|
||||||
id="addressFormAddress1"
|
|
||||||
name="address1"
|
|
||||||
value={addressFormData.address1}
|
|
||||||
onChange={(value) => {
|
|
||||||
const syntheticEvent = {
|
|
||||||
target: {
|
|
||||||
name: "address1",
|
|
||||||
value,
|
|
||||||
type: "text",
|
|
||||||
},
|
|
||||||
} as React.ChangeEvent<HTMLInputElement>;
|
|
||||||
handleAddressFormChange(syntheticEvent);
|
|
||||||
}}
|
|
||||||
onPlaceSelect={handlePlaceSelect}
|
|
||||||
placeholder="Start typing an address..."
|
|
||||||
className="form-control"
|
|
||||||
required
|
|
||||||
countryRestriction="us"
|
|
||||||
types={["address"]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label
|
|
||||||
htmlFor="addressFormAddress2"
|
|
||||||
className="form-label"
|
|
||||||
>
|
|
||||||
Address Line 2
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
id="addressFormAddress2"
|
|
||||||
name="address2"
|
|
||||||
value={addressFormData.address2}
|
|
||||||
onChange={handleAddressFormChange}
|
|
||||||
placeholder="Apt, Suite, Unit, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row mb-3">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<label
|
|
||||||
htmlFor="addressFormCity"
|
|
||||||
className="form-label"
|
|
||||||
>
|
|
||||||
City *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
id="addressFormCity"
|
|
||||||
name="city"
|
|
||||||
value={addressFormData.city}
|
|
||||||
onChange={handleAddressFormChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label
|
|
||||||
htmlFor="addressFormState"
|
|
||||||
className="form-label"
|
|
||||||
>
|
|
||||||
State *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
id="addressFormState"
|
|
||||||
name="state"
|
|
||||||
value={addressFormData.state}
|
|
||||||
onChange={handleAddressFormChange}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select State</option>
|
|
||||||
{usStates.map((state) => (
|
|
||||||
<option key={state} value={state}>
|
|
||||||
{state}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-3">
|
|
||||||
<label
|
|
||||||
htmlFor="addressFormZipCode"
|
|
||||||
className="form-label"
|
|
||||||
>
|
|
||||||
ZIP Code *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control"
|
|
||||||
id="addressFormZipCode"
|
|
||||||
name="zipCode"
|
|
||||||
value={addressFormData.zipCode}
|
|
||||||
onChange={handleAddressFormChange}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSaveAddress(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="12345"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={handleSaveAddress}
|
|
||||||
>
|
|
||||||
{editingAddressId
|
|
||||||
? "Update Address"
|
|
||||||
: "Save Address"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleCancelAddressForm}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
{editing ? (
|
|
||||||
<div className="d-flex gap-2">
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-primary"
|
||||||
onClick={handleCancel}
|
onClick={() => setEditing(true)}
|
||||||
>
|
>
|
||||||
Cancel
|
Edit Information
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</form>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
Edit Information
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1225,10 +1266,13 @@ const Profile: React.FC = () => {
|
|||||||
<p className="mb-1 small">
|
<p className="mb-1 small">
|
||||||
<strong>Owner:</strong>{" "}
|
<strong>Owner:</strong>{" "}
|
||||||
<span
|
<span
|
||||||
onClick={() => navigate(`/users/${rental.ownerId}`)}
|
onClick={() =>
|
||||||
|
navigate(`/users/${rental.ownerId}`)
|
||||||
|
}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{rental.owner.firstName} {rental.owner.lastName}
|
{rental.owner.firstName}{" "}
|
||||||
|
{rental.owner.lastName}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1330,10 +1374,13 @@ const Profile: React.FC = () => {
|
|||||||
<p className="mb-1 small">
|
<p className="mb-1 small">
|
||||||
<strong>Renter:</strong>{" "}
|
<strong>Renter:</strong>{" "}
|
||||||
<span
|
<span
|
||||||
onClick={() => navigate(`/users/${rental.renterId}`)}
|
onClick={() =>
|
||||||
|
navigate(`/users/${rental.renterId}`)
|
||||||
|
}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{rental.renter.firstName} {rental.renter.lastName}
|
{rental.renter.firstName}{" "}
|
||||||
|
{rental.renter.lastName}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1460,26 +1507,84 @@ const Profile: React.FC = () => {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="itemRequestNotificationRadius" className="form-label">
|
<div className="form-check">
|
||||||
Item Requests Notification Radius
|
<input
|
||||||
</label>
|
className="form-check-input"
|
||||||
<select
|
type="checkbox"
|
||||||
className="form-select"
|
id="enableItemRequestNotifications"
|
||||||
id="itemRequestNotificationRadius"
|
checked={
|
||||||
name="itemRequestNotificationRadius"
|
formData.itemRequestNotificationRadius !== null &&
|
||||||
value={formData.itemRequestNotificationRadius}
|
formData.itemRequestNotificationRadius !== undefined
|
||||||
onChange={handleNotificationRadiusChange}
|
}
|
||||||
>
|
onChange={async (e) => {
|
||||||
<option value="5">5 miles</option>
|
const isEnabled = e.target.checked;
|
||||||
<option value="10">10 miles</option>
|
const newRadius = isEnabled ? 10 : null; // Default to 10 miles when enabled
|
||||||
<option value="25">25 miles</option>
|
setFormData((prev) => ({
|
||||||
<option value="50">50 miles</option>
|
...prev,
|
||||||
<option value="100">100 miles</option>
|
itemRequestNotificationRadius: newRadius,
|
||||||
</select>
|
}));
|
||||||
<div className="form-text">
|
setError(null);
|
||||||
You'll receive notifications when someone posts an item request within this distance from your primary address
|
|
||||||
|
try {
|
||||||
|
const response = await userAPI.updateProfile({
|
||||||
|
itemRequestNotificationRadius: newRadius,
|
||||||
|
});
|
||||||
|
setProfileData(response.data);
|
||||||
|
updateUser(response.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(
|
||||||
|
"Notification preferences update error:",
|
||||||
|
err.response?.data
|
||||||
|
);
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
"Failed to update notification preferences";
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
className="form-check-label"
|
||||||
|
htmlFor="enableItemRequestNotifications"
|
||||||
|
>
|
||||||
|
Enable Item Request Notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-text mb-3">
|
||||||
|
Receive notifications when someone nearby posts an item
|
||||||
|
request
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{formData.itemRequestNotificationRadius !== null &&
|
||||||
|
formData.itemRequestNotificationRadius !== undefined && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label
|
||||||
|
htmlFor="itemRequestNotificationRadius"
|
||||||
|
className="form-label"
|
||||||
|
>
|
||||||
|
Notification Radius
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="itemRequestNotificationRadius"
|
||||||
|
name="itemRequestNotificationRadius"
|
||||||
|
value={formData.itemRequestNotificationRadius}
|
||||||
|
onChange={handleNotificationRadiusChange}
|
||||||
|
>
|
||||||
|
<option value="5">5 miles</option>
|
||||||
|
<option value="10">10 miles</option>
|
||||||
|
<option value="25">25 miles</option>
|
||||||
|
<option value="50">50 miles</option>
|
||||||
|
<option value="100">100 miles</option>
|
||||||
|
</select>
|
||||||
|
<div className="form-text">
|
||||||
|
You'll receive notifications for item requests within
|
||||||
|
this distance from your primary address
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ export const itemAPI = {
|
|||||||
deleteItem: (id: string) => api.delete(`/items/${id}`),
|
deleteItem: (id: string) => api.delete(`/items/${id}`),
|
||||||
getRecommendations: () => api.get("/items/recommendations"),
|
getRecommendations: () => api.get("/items/recommendations"),
|
||||||
getItemReviews: (id: string) => api.get(`/items/${id}/reviews`),
|
getItemReviews: (id: string) => api.get(`/items/${id}/reviews`),
|
||||||
|
// Admin endpoints
|
||||||
|
adminSoftDeleteItem: (id: string, reason: string) =>
|
||||||
|
api.delete(`/items/admin/${id}`, { data: { reason } }),
|
||||||
|
adminRestoreItem: (id: string) => api.patch(`/items/admin/${id}/restore`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rentalAPI = {
|
export const rentalAPI = {
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export interface Item {
|
|||||||
};
|
};
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
owner?: User;
|
owner?: User;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
deletedBy?: string;
|
||||||
|
deletedAt?: string;
|
||||||
|
deletionReason?: string;
|
||||||
|
deleter?: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user