Item request notifications

This commit is contained in:
jackiettran
2025-11-18 22:28:47 -05:00
parent 026e748bf8
commit 413ac6b6e2
11 changed files with 875 additions and 224 deletions

View File

@@ -57,6 +57,18 @@ const ForumPost = sequelize.define('ForumPost', {
allowNull: true,
defaultValue: []
},
zipCode: {
type: DataTypes.STRING,
allowNull: true
},
latitude: {
type: DataTypes.DECIMAL(10, 8),
allowNull: true
},
longitude: {
type: DataTypes.DECIMAL(11, 8),
allowNull: true
},
isDeleted: {
type: DataTypes.BOOLEAN,
defaultValue: false

View File

@@ -142,6 +142,15 @@ const User = sequelize.define(
defaultValue: "user",
allowNull: false,
},
itemRequestNotificationRadius: {
type: DataTypes.INTEGER,
defaultValue: 10,
allowNull: true,
validate: {
min: 1,
max: 100,
},
},
},
{
hooks: {

View File

@@ -5,6 +5,8 @@ const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
const logger = require('../utils/logger');
const emailServices = require('../services/email');
const googleMapsService = require('../services/googleMapsService');
const locationService = require('../services/locationService');
const router = express.Router();
// Helper function to build nested comment tree
@@ -238,7 +240,7 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
// POST /api/forum/posts - Create new post
router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => {
try {
let { title, content, category, tags } = req.body;
let { title, content, category, tags, zipCode } = req.body;
// Parse tags if they come as JSON string (from FormData)
if (typeof tags === 'string') {
@@ -252,12 +254,42 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
// Extract image filenames if uploaded
const images = req.files ? req.files.map(file => file.filename) : [];
// Initialize location fields
let latitude = null;
let longitude = null;
// Geocode zip code for item requests
if (category === 'item_request' && zipCode) {
try {
const geocodeResult = await googleMapsService.geocodeAddress(zipCode);
latitude = geocodeResult.latitude;
longitude = geocodeResult.longitude;
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Geocoded zip code for item request", {
zipCode,
latitude,
longitude
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Geocoding failed for item request", {
error: error.message,
zipCode
});
// Continue without coordinates - post will still be created
}
}
const post = await ForumPost.create({
title,
content,
category,
authorId: req.user.id,
images
images,
zipCode: zipCode || null,
latitude,
longitude
});
// Create tags if provided
@@ -295,6 +327,71 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
});
res.status(201).json(postWithDetails);
// Send location-based notifications for item requests (asynchronously)
if (category === 'item_request' && latitude && longitude) {
(async () => {
try {
// Find all users within maximum radius (100 miles)
const nearbyUsers = await locationService.findUsersInRadius(
latitude,
longitude,
100
);
const postAuthor = await User.findByPk(req.user.id);
let notificationsSent = 0;
let usersChecked = 0;
for (const user of nearbyUsers) {
// Don't notify the requester
if (user.id !== req.user.id) {
usersChecked++;
// Get user's notification preference
const userProfile = await User.findByPk(user.id, {
attributes: ['itemRequestNotificationRadius']
});
const userPreferredRadius = userProfile?.itemRequestNotificationRadius || 10;
// Only notify if within user's preferred radius
if (parseFloat(user.distance) <= userPreferredRadius) {
try {
await emailServices.forum.sendItemRequestNotification(
user,
postAuthor,
post,
user.distance
);
notificationsSent++;
} catch (emailError) {
logger.error("Failed to send item request notification", {
error: emailError.message,
recipientId: user.id,
postId: post.id
});
}
}
}
}
logger.info("Item request notifications sent", {
postId: post.id,
totalNearbyUsers: nearbyUsers.length,
usersChecked,
notificationsSent
});
} catch (error) {
logger.error("Failed to process item request notifications", {
error: error.message,
stack: error.stack,
postId: post.id
});
}
})();
}
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum post creation failed", {

View File

@@ -221,7 +221,8 @@ router.put('/profile', authenticateToken, async (req, res) => {
city,
state,
zipCode,
country
country,
itemRequestNotificationRadius
} = req.body;
// Build update object, excluding empty email
@@ -234,7 +235,8 @@ router.put('/profile', authenticateToken, async (req, res) => {
city,
state,
zipCode,
country
country,
itemRequestNotificationRadius
};
// Only include email if it's not empty

View File

@@ -63,6 +63,7 @@ class TemplateManager {
"forumAnswerAcceptedToCommentAuthor.html",
"forumThreadActivityToParticipant.html",
"forumPostClosed.html",
"forumItemRequestNotification.html",
];
for (const templateFile of templateFiles) {

View File

@@ -8,6 +8,7 @@ const TemplateManager = require("../core/TemplateManager");
* - Sending reply notifications to comment authors
* - Sending answer accepted notifications
* - Sending thread activity notifications to participants
* - Sending location-based item request notifications to nearby users
*/
class ForumEmailService {
constructor() {
@@ -384,6 +385,66 @@ class ForumEmailService {
return { success: false, error: error.message };
}
}
/**
* Send notification to nearby users about an item request
* @param {Object} recipient - Recipient user object
* @param {string} recipient.firstName - Recipient's first name
* @param {string} recipient.email - Recipient's email
* @param {Object} requester - User who posted the item request
* @param {string} requester.firstName - Requester's first name
* @param {string} requester.lastName - Requester's last name
* @param {Object} post - Forum post object (item request)
* @param {number} post.id - Post ID
* @param {string} post.title - Item being requested
* @param {string} post.content - Request description
* @param {string|number} distance - Distance from recipient to request location (in miles)
* @returns {Promise<{success: boolean, messageId?: string, error?: string}>}
*/
async sendItemRequestNotification(recipient, requester, post, distance) {
if (!this.initialized) {
await this.initialize();
}
try {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
const postUrl = `${frontendUrl}/forum/posts/${post.id}`;
const variables = {
recipientName: recipient.firstName || "there",
requesterName:
`${requester.firstName} ${requester.lastName}`.trim() || "Someone",
itemRequested: post.title,
requestDescription: post.content,
postUrl: postUrl,
distance: distance,
};
const htmlContent = await this.templateManager.renderTemplate(
"forumItemRequestNotification",
variables
);
const subject = `Someone nearby is looking for: ${post.title}`;
const result = await this.emailClient.sendEmail(
recipient.email,
subject,
htmlContent
);
if (result.success) {
console.log(
`Item request notification email sent to ${recipient.email}`
);
}
return result;
} catch (error) {
console.error("Failed to send item request notification email:", error);
return { success: false, error: error.message };
}
}
}
module.exports = ForumEmailService;

View File

@@ -0,0 +1,112 @@
const { sequelize } = require('../models');
const { QueryTypes } = require('sequelize');
class LocationService {
/**
* Find users within a specified radius of coordinates
* Uses the Haversine formula to calculate great-circle distance between two points
*
* @param {number} latitude - Center point latitude
* @param {number} longitude - Center point longitude
* @param {number} radiusMiles - Search radius in miles (default: 10)
* @returns {Promise<Array>} Array of users with their distance from the center point
*/
async findUsersInRadius(latitude, longitude, radiusMiles = 10) {
if (!latitude || !longitude) {
throw new Error('Latitude and longitude are required');
}
if (radiusMiles <= 0 || radiusMiles > 100) {
throw new Error('Radius must be between 1 and 100 miles');
}
try {
// Haversine formula:
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
// * cos(radians(lng2) - radians(lng1))
// + sin(radians(lat1)) * sin(radians(lat2)))
// Note: 3959 is Earth's radius in miles
const query = `
SELECT
u.id,
u.email,
u."firstName",
u."lastName",
ua.latitude,
ua.longitude,
(3959 * acos(
LEAST(1.0,
cos(radians(:lat)) * cos(radians(ua.latitude))
* cos(radians(ua.longitude) - radians(:lng))
+ sin(radians(:lat)) * sin(radians(ua.latitude))
)
)) AS distance
FROM "Users" u
INNER JOIN "UserAddresses" ua ON u.id = ua."userId"
WHERE ua."isPrimary" = true
AND ua.latitude IS NOT NULL
AND ua.longitude IS NOT NULL
HAVING distance < :radiusMiles
ORDER BY distance ASC
`;
const users = await sequelize.query(query, {
replacements: {
lat: parseFloat(latitude),
lng: parseFloat(longitude),
radiusMiles: parseFloat(radiusMiles)
},
type: QueryTypes.SELECT
});
return users.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
latitude: parseFloat(user.latitude),
longitude: parseFloat(user.longitude),
distance: parseFloat(user.distance).toFixed(2) // Round to 2 decimal places
}));
} catch (error) {
console.error('Error finding users in radius:', error);
throw new Error(`Failed to find users in radius: ${error.message}`);
}
}
/**
* Calculate distance between two points using Haversine formula
*
* @param {number} lat1 - First point latitude
* @param {number} lon1 - First point longitude
* @param {number} lat2 - Second point latitude
* @param {number} lon2 - Second point longitude
* @returns {number} Distance in miles
*/
calculateDistance(lat1, lon1, lat2, lon2) {
const R = 3959; // Earth's radius in miles
const dLat = this.toRadians(lat2 - lat1);
const dLon = this.toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return distance;
}
/**
* Convert degrees to radians
* @param {number} degrees
* @returns {number} Radians
*/
toRadians(degrees) {
return degrees * (Math.PI / 180);
}
}
module.exports = new LocationService();

View File

@@ -0,0 +1,285 @@
<!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>Item Request Near You</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0d4f7;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Distance badge */
.distance-badge {
display: inline-block;
background-color: #e7f3ff;
color: #0066cc;
padding: 6px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin: 10px 0;
}
/* Info box */
.info-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0;
color: #856404;
}
/* Item request box */
.request-box {
background-color: #f8f9fa;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.request-box .title {
font-size: 18px;
font-weight: 600;
color: #495057;
margin: 0 0 15px 0;
}
.request-box .description {
color: #212529;
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.request-box .requester {
font-size: 14px;
color: #6c757d;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #dee2e6;
}
/* Help section */
.help-section {
background-color: #d4edda;
border: 1px solid #c3e6cb;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
text-align: center;
}
.help-section p {
margin: 0;
color: #155724;
font-weight: 500;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.request-box {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Item Request Near You</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>Someone nearby is looking for an item!</h1>
<div class="distance-badge">📍 About {{distance}} miles away</div>
<p><strong>{{requesterName}}</strong> posted an item request in your area. You might be able to help!</p>
<div class="request-box">
<div class="title">{{itemRequested}}</div>
<div class="description">{{requestDescription}}</div>
<div class="requester">Posted by {{requesterName}}</div>
</div>
<div class="help-section">
<p>💡 Have this item? You can help a neighbor and potentially earn money!</p>
</div>
<a href="{{postUrl}}" class="button">View Request & Respond</a>
<p>Click the button above to see the full details and offer your help if you have the item they're looking for.</p>
<div class="info-box">
<p><strong>Why did I get this?</strong> You're receiving this notification because you're located within the requested area for this item. Help build your local community by responding to nearby requests!</p>
</div>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because someone near you posted an item request.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -20,6 +20,7 @@ const CreateForumPost: React.FC = () => {
| "community_resources"
| "general_discussion",
tags: [] as string[],
zipCode: user?.zipCode || "",
});
const [imageFiles, setImageFiles] = useState<File[]>([]);
@@ -111,6 +112,11 @@ const CreateForumPost: React.FC = () => {
return;
}
if (formData.category === "item_request" && !formData.zipCode.trim()) {
setError("Zip code is required for item requests");
return;
}
try {
setIsSubmitting(true);
@@ -125,6 +131,11 @@ const CreateForumPost: React.FC = () => {
submitData.append('tags', JSON.stringify(formData.tags));
}
// Add location data for item requests
if (formData.category === 'item_request' && formData.zipCode) {
submitData.append('zipCode', formData.zipCode);
}
// Add images
imageFiles.forEach((file) => {
submitData.append('images', file);
@@ -247,6 +258,31 @@ const CreateForumPost: React.FC = () => {
</div>
</div>
{/* Location fields for item requests */}
{formData.category === "item_request" && (
<div className="mb-3">
<label htmlFor="zipCode" className="form-label">
Zip Code <span className="text-danger">*</span>
</label>
<input
type="text"
className="form-control"
id="zipCode"
name="zipCode"
value={formData.zipCode}
onChange={handleInputChange}
placeholder="Enter your zip code..."
maxLength={10}
disabled={isSubmitting}
required
/>
<div className="form-text">
Your zip code helps notify nearby users who might have
the item you're looking for
</div>
</div>
)}
{/* Content */}
<div className="mb-3">
<label htmlFor="content" className="form-label">

View File

@@ -34,6 +34,7 @@ const Profile: React.FC = () => {
zipCode: "",
country: "",
profileImage: "",
itemRequestNotificationRadius: 10,
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
@@ -137,6 +138,7 @@ const Profile: React.FC = () => {
zipCode: response.data.zipCode || "",
country: response.data.country || "",
profileImage: response.data.profileImage || "",
itemRequestNotificationRadius: response.data.itemRequestNotificationRadius || 10,
});
if (response.data.profileImage) {
setImagePreview(getImageUrl(response.data.profileImage));
@@ -259,7 +261,7 @@ const Profile: React.FC = () => {
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
@@ -356,6 +358,7 @@ const Profile: React.FC = () => {
zipCode: profileData.zipCode || "",
country: profileData.country || "",
profileImage: profileData.profileImage || "",
itemRequestNotificationRadius: profileData.itemRequestNotificationRadius || 10,
});
setImagePreview(
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
@@ -835,6 +838,251 @@ const Profile: React.FC = () => {
/>
</div>
<hr className="my-4" />
{/* Saved Addresses Section */}
<div className="mb-3">
<label className="form-label">Saved Addresses</label>
{addressesLoading ? (
<div className="text-center py-3">
<div
className="spinner-border spinner-border-sm"
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 && (
<form onSubmit={handleSaveAddress}>
<div className="row mb-3">
<div className="col-md-6">
<label
htmlFor="addressFormAddress1"
className="form-label"
>
Address Line 1 *
</label>
<input
type="text"
className="form-control"
id="addressFormAddress1"
name="address1"
value={addressFormData.address1}
onChange={handleAddressFormChange}
placeholder=""
required
/>
</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}
placeholder="12345"
required
/>
</div>
</div>
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary">
{editingAddressId
? "Update Address"
: "Save Address"}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleCancelAddressForm}
>
Cancel
</button>
</div>
</form>
)}
</>
)}
</div>
<hr className="my-4" />
{/* Notification Preferences Section */}
<div className="mb-3">
<label className="form-label">Notification Preferences</label>
<div className="mb-3">
<label htmlFor="itemRequestNotificationRadius" className="form-label">
Item Requests Notification Radius
</label>
<select
className="form-select"
id="itemRequestNotificationRadius"
name="itemRequestNotificationRadius"
value={formData.itemRequestNotificationRadius}
onChange={handleChange}
disabled={!editing}
>
<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 when someone posts an item request within this distance from your primary address
</div>
</div>
</div>
<hr className="my-4" />
{editing ? (
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary">
@@ -1180,219 +1428,6 @@ const Profile: React.FC = () => {
<div>
<h4 className="mb-4">Owner Settings</h4>
{/* Addresses Card */}
<div className="card mb-4">
<div className="card-body">
<h5 className="card-title">Saved Addresses</h5>
{addressesLoading ? (
<div className="text-center py-3">
<div
className="spinner-border spinner-border-sm"
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">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 && (
<form onSubmit={handleSaveAddress}>
<div className="row mb-3">
<div className="col-md-6">
<label
htmlFor="addressFormAddress1"
className="form-label"
>
Address Line 1 *
</label>
<input
type="text"
className="form-control"
id="addressFormAddress1"
name="address1"
value={addressFormData.address1}
onChange={handleAddressFormChange}
placeholder=""
required
/>
</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}
placeholder="12345"
required
/>
</div>
</div>
<div className="d-flex gap-2">
<button type="submit" className="btn btn-primary">
{editingAddressId
? "Update Address"
: "Save Address"}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleCancelAddressForm}
>
Cancel
</button>
</div>
</form>
)}
</>
)}
</div>
</div>
{/* Availability Card */}
<div className="card">
<div className="card-body">

View File

@@ -32,6 +32,7 @@ export interface User {
role?: "user" | "admin";
stripeConnectedAccountId?: string;
addresses?: Address[];
itemRequestNotificationRadius?: number;
}
export interface Message {