Compare commits

...

2 Commits

Author SHA1 Message Date
jackiettran
b2f18d77f6 admin can soft delete listings 2025-11-20 17:14:40 -05:00
jackiettran
88c831419c disable item request notifications 2025-11-20 15:28:16 -05:00
13 changed files with 1239 additions and 369 deletions

View File

@@ -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;

View File

@@ -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" });

View File

@@ -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,

View File

@@ -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;

View File

@@ -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",

View File

@@ -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;

View 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>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -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 ? (
<> <>

View File

@@ -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>
); );
}; };

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 = {

View File

@@ -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;
} }