Compare commits
2 Commits
026e748bf8
...
83872fe039
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83872fe039 | ||
|
|
413ac6b6e2 |
@@ -57,6 +57,18 @@ const ForumPost = sequelize.define('ForumPost', {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: []
|
defaultValue: []
|
||||||
},
|
},
|
||||||
|
zipCode: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
latitude: {
|
||||||
|
type: DataTypes.DECIMAL(10, 8),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
longitude: {
|
||||||
|
type: DataTypes.DECIMAL(11, 8),
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
isDeleted: {
|
isDeleted: {
|
||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ const User = sequelize.define(
|
|||||||
defaultValue: "user",
|
defaultValue: "user",
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
itemRequestNotificationRadius: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 10,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hooks: {
|
hooks: {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware
|
|||||||
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const emailServices = require('../services/email');
|
const emailServices = require('../services/email');
|
||||||
|
const googleMapsService = require('../services/googleMapsService');
|
||||||
|
const locationService = require('../services/locationService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Helper function to build nested comment tree
|
// 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
|
// POST /api/forum/posts - Create new post
|
||||||
router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => {
|
router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
let { title, content, category, tags } = req.body;
|
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng } = req.body;
|
||||||
|
|
||||||
// Parse tags if they come as JSON string (from FormData)
|
// Parse tags if they come as JSON string (from FormData)
|
||||||
if (typeof tags === 'string') {
|
if (typeof tags === 'string') {
|
||||||
@@ -252,12 +254,69 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
|||||||
// Extract image filenames if uploaded
|
// Extract image filenames if uploaded
|
||||||
const images = req.files ? req.files.map(file => file.filename) : [];
|
const images = req.files ? req.files.map(file => file.filename) : [];
|
||||||
|
|
||||||
|
// Initialize location fields
|
||||||
|
let latitude = null;
|
||||||
|
let longitude = null;
|
||||||
|
|
||||||
|
// Use provided coordinates if available, otherwise geocode zip code
|
||||||
|
if (category === 'item_request' && zipCode) {
|
||||||
|
// If coordinates were provided from a saved address, use them directly
|
||||||
|
if (providedLat && providedLng) {
|
||||||
|
latitude = parseFloat(providedLat);
|
||||||
|
longitude = parseFloat(providedLng);
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Using provided coordinates for item request", {
|
||||||
|
zipCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
source: 'saved_address'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Otherwise, geocode the zip code
|
||||||
|
try {
|
||||||
|
const geocodeResult = await googleMapsService.geocodeAddress(zipCode);
|
||||||
|
|
||||||
|
// Check if geocoding was successful
|
||||||
|
if (geocodeResult.error) {
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.error("Geocoding failed for item request", {
|
||||||
|
error: geocodeResult.error,
|
||||||
|
status: geocodeResult.status,
|
||||||
|
zipCode
|
||||||
|
});
|
||||||
|
} else if (geocodeResult.latitude && geocodeResult.longitude) {
|
||||||
|
latitude = geocodeResult.latitude;
|
||||||
|
longitude = geocodeResult.longitude;
|
||||||
|
|
||||||
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
|
reqLogger.info("Geocoded zip code for item request", {
|
||||||
|
zipCode,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
source: 'geocoded'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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({
|
const post = await ForumPost.create({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
category,
|
category,
|
||||||
authorId: req.user.id,
|
authorId: req.user.id,
|
||||||
images
|
images,
|
||||||
|
zipCode: zipCode || null,
|
||||||
|
latitude,
|
||||||
|
longitude
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create tags if provided
|
// Create tags if provided
|
||||||
@@ -295,6 +354,111 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(postWithDetails);
|
res.status(201).json(postWithDetails);
|
||||||
|
|
||||||
|
// Send location-based notifications for item requests (asynchronously)
|
||||||
|
if (category === 'item_request' && latitude && longitude) {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
logger.info("Starting item request notifications", {
|
||||||
|
postId: post.id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
zipCode
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find all users within maximum radius (100 miles)
|
||||||
|
const nearbyUsers = await locationService.findUsersInRadius(
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Found nearby users", {
|
||||||
|
postId: post.id,
|
||||||
|
count: nearbyUsers.length,
|
||||||
|
users: nearbyUsers.map(u => ({ id: u.id, distance: u.distance }))
|
||||||
|
});
|
||||||
|
|
||||||
|
const postAuthor = await User.findByPk(req.user.id);
|
||||||
|
|
||||||
|
let notificationsSent = 0;
|
||||||
|
let usersChecked = 0;
|
||||||
|
let usersSkipped = 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;
|
||||||
|
|
||||||
|
logger.info("Checking user notification eligibility", {
|
||||||
|
postId: post.id,
|
||||||
|
userId: user.id,
|
||||||
|
userEmail: user.email,
|
||||||
|
userCoordinates: { lat: user.latitude, lng: user.longitude },
|
||||||
|
postCoordinates: { lat: latitude, lng: longitude },
|
||||||
|
userDistance: user.distance,
|
||||||
|
userPreferredRadius,
|
||||||
|
willNotify: parseFloat(user.distance) <= userPreferredRadius
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only notify if within user's preferred radius
|
||||||
|
if (parseFloat(user.distance) <= userPreferredRadius) {
|
||||||
|
try {
|
||||||
|
await emailServices.forum.sendItemRequestNotification(
|
||||||
|
user,
|
||||||
|
postAuthor,
|
||||||
|
post,
|
||||||
|
user.distance
|
||||||
|
);
|
||||||
|
notificationsSent++;
|
||||||
|
logger.info("Sent notification to user", {
|
||||||
|
postId: post.id,
|
||||||
|
userId: user.id,
|
||||||
|
distance: user.distance
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
logger.error("Failed to send item request notification", {
|
||||||
|
error: emailError.message,
|
||||||
|
recipientId: user.id,
|
||||||
|
postId: post.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
usersSkipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Item request notifications complete", {
|
||||||
|
postId: post.id,
|
||||||
|
totalNearbyUsers: nearbyUsers.length,
|
||||||
|
usersChecked,
|
||||||
|
usersSkipped,
|
||||||
|
notificationsSent
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to process item request notifications", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
postId: post.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
} else if (category === 'item_request') {
|
||||||
|
logger.warn("Item request created without location", {
|
||||||
|
postId: post.id,
|
||||||
|
zipCode,
|
||||||
|
hasLatitude: !!latitude,
|
||||||
|
hasLongitude: !!longitude
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const reqLogger = logger.withRequestId(req.id);
|
const reqLogger = logger.withRequestId(req.id);
|
||||||
reqLogger.error("Forum post creation failed", {
|
reqLogger.error("Forum post creation failed", {
|
||||||
|
|||||||
@@ -211,19 +211,20 @@ router.get('/:id', async (req, res) => {
|
|||||||
|
|
||||||
router.put('/profile', authenticateToken, async (req, res) => {
|
router.put('/profile', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
address1,
|
address1,
|
||||||
address2,
|
address2,
|
||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
zipCode,
|
zipCode,
|
||||||
country
|
country,
|
||||||
|
itemRequestNotificationRadius
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Build update object, excluding empty email
|
// Build update object, excluding empty email
|
||||||
const updateData = {
|
const updateData = {
|
||||||
firstName,
|
firstName,
|
||||||
@@ -234,7 +235,8 @@ router.put('/profile', authenticateToken, async (req, res) => {
|
|||||||
city,
|
city,
|
||||||
state,
|
state,
|
||||||
zipCode,
|
zipCode,
|
||||||
country
|
country,
|
||||||
|
itemRequestNotificationRadius
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include email if it's not empty
|
// Only include email if it's not empty
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class TemplateManager {
|
|||||||
"forumAnswerAcceptedToCommentAuthor.html",
|
"forumAnswerAcceptedToCommentAuthor.html",
|
||||||
"forumThreadActivityToParticipant.html",
|
"forumThreadActivityToParticipant.html",
|
||||||
"forumPostClosed.html",
|
"forumPostClosed.html",
|
||||||
|
"forumItemRequestNotification.html",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const templateFile of templateFiles) {
|
for (const templateFile of templateFiles) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const TemplateManager = require("../core/TemplateManager");
|
|||||||
* - Sending reply notifications to comment authors
|
* - Sending reply notifications to comment authors
|
||||||
* - Sending answer accepted notifications
|
* - Sending answer accepted notifications
|
||||||
* - Sending thread activity notifications to participants
|
* - Sending thread activity notifications to participants
|
||||||
|
* - Sending location-based item request notifications to nearby users
|
||||||
*/
|
*/
|
||||||
class ForumEmailService {
|
class ForumEmailService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -384,6 +385,66 @@ class ForumEmailService {
|
|||||||
return { success: false, error: error.message };
|
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;
|
module.exports = ForumEmailService;
|
||||||
|
|||||||
127
backend/services/locationService.js
Normal file
127
backend/services/locationService.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Finding users in radius:', {
|
||||||
|
centerLatitude: latitude,
|
||||||
|
centerLongitude: longitude,
|
||||||
|
radiusMiles
|
||||||
|
});
|
||||||
|
|
||||||
|
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 * FROM (
|
||||||
|
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
|
||||||
|
) AS user_distances
|
||||||
|
WHERE 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
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Users found in radius:', users.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
userLat: u.latitude,
|
||||||
|
userLng: u.longitude,
|
||||||
|
distance: parseFloat(u.distance).toFixed(2)
|
||||||
|
})));
|
||||||
|
|
||||||
|
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();
|
||||||
295
backend/templates/emails/forumItemRequestNotification.html
Normal file
295
backend/templates/emails/forumItemRequestNotification.html
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<!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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
|
|
||||||
|
<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!</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>© 2024 RentAll. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -27,11 +27,9 @@
|
|||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
|
||||||
"start:dev": "dotenv -e .env.dev react-scripts start",
|
"start:dev": "dotenv -e .env.dev react-scripts start",
|
||||||
"start:qa": "dotenv -e .env.qa react-scripts start",
|
"start:qa": "dotenv -e .env.qa react-scripts start",
|
||||||
"start:prod": "dotenv -e .env.prod react-scripts start",
|
"start:prod": "dotenv -e .env.prod react-scripts start",
|
||||||
"build": "react-scripts build",
|
|
||||||
"build:dev": "dotenv -e .env.dev react-scripts build",
|
"build:dev": "dotenv -e .env.dev react-scripts build",
|
||||||
"build:qa": "dotenv -e .env.qa react-scripts build",
|
"build:qa": "dotenv -e .env.qa react-scripts build",
|
||||||
"build:prod": "dotenv -e .env.prod react-scripts build",
|
"build:prod": "dotenv -e .env.prod react-scripts build",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
} from "../services/geocodingService";
|
} from "../services/geocodingService";
|
||||||
import AddressAutocomplete from "./AddressAutocomplete";
|
import AddressAutocomplete from "./AddressAutocomplete";
|
||||||
import { PlaceDetails } from "../services/placesService";
|
import { PlaceDetails } from "../services/placesService";
|
||||||
|
import {
|
||||||
|
useAddressAutocomplete,
|
||||||
|
usStates,
|
||||||
|
} from "../hooks/useAddressAutocomplete";
|
||||||
|
|
||||||
interface LocationFormData {
|
interface LocationFormData {
|
||||||
address1: string;
|
address1: string;
|
||||||
@@ -29,123 +33,14 @@ interface LocationFormProps {
|
|||||||
onAddressSelect: (addressId: string) => void;
|
onAddressSelect: (addressId: string) => void;
|
||||||
formatAddressDisplay: (address: Address) => string;
|
formatAddressDisplay: (address: Address) => string;
|
||||||
onCoordinatesChange?: (latitude: number, longitude: number) => void;
|
onCoordinatesChange?: (latitude: number, longitude: number) => void;
|
||||||
onGeocodeRef?: (geocodeFunction: () => Promise<boolean>) => void;
|
onGeocodeRef?: (
|
||||||
|
geocodeFunction: () => Promise<{
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
} | null>
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// State constants - moved to top to avoid hoisting issues
|
|
||||||
const usStates = [
|
|
||||||
"Alabama",
|
|
||||||
"Alaska",
|
|
||||||
"Arizona",
|
|
||||||
"Arkansas",
|
|
||||||
"California",
|
|
||||||
"Colorado",
|
|
||||||
"Connecticut",
|
|
||||||
"Delaware",
|
|
||||||
"Florida",
|
|
||||||
"Georgia",
|
|
||||||
"Hawaii",
|
|
||||||
"Idaho",
|
|
||||||
"Illinois",
|
|
||||||
"Indiana",
|
|
||||||
"Iowa",
|
|
||||||
"Kansas",
|
|
||||||
"Kentucky",
|
|
||||||
"Louisiana",
|
|
||||||
"Maine",
|
|
||||||
"Maryland",
|
|
||||||
"Massachusetts",
|
|
||||||
"Michigan",
|
|
||||||
"Minnesota",
|
|
||||||
"Mississippi",
|
|
||||||
"Missouri",
|
|
||||||
"Montana",
|
|
||||||
"Nebraska",
|
|
||||||
"Nevada",
|
|
||||||
"New Hampshire",
|
|
||||||
"New Jersey",
|
|
||||||
"New Mexico",
|
|
||||||
"New York",
|
|
||||||
"North Carolina",
|
|
||||||
"North Dakota",
|
|
||||||
"Ohio",
|
|
||||||
"Oklahoma",
|
|
||||||
"Oregon",
|
|
||||||
"Pennsylvania",
|
|
||||||
"Rhode Island",
|
|
||||||
"South Carolina",
|
|
||||||
"South Dakota",
|
|
||||||
"Tennessee",
|
|
||||||
"Texas",
|
|
||||||
"Utah",
|
|
||||||
"Vermont",
|
|
||||||
"Virginia",
|
|
||||||
"Washington",
|
|
||||||
"West Virginia",
|
|
||||||
"Wisconsin",
|
|
||||||
"Wyoming",
|
|
||||||
];
|
|
||||||
|
|
||||||
// State code to full name mapping for Google Places API integration
|
|
||||||
const stateCodeToName: { [key: string]: string } = {
|
|
||||||
AL: "Alabama",
|
|
||||||
AK: "Alaska",
|
|
||||||
AZ: "Arizona",
|
|
||||||
AR: "Arkansas",
|
|
||||||
CA: "California",
|
|
||||||
CO: "Colorado",
|
|
||||||
CT: "Connecticut",
|
|
||||||
DE: "Delaware",
|
|
||||||
FL: "Florida",
|
|
||||||
GA: "Georgia",
|
|
||||||
HI: "Hawaii",
|
|
||||||
ID: "Idaho",
|
|
||||||
IL: "Illinois",
|
|
||||||
IN: "Indiana",
|
|
||||||
IA: "Iowa",
|
|
||||||
KS: "Kansas",
|
|
||||||
KY: "Kentucky",
|
|
||||||
LA: "Louisiana",
|
|
||||||
ME: "Maine",
|
|
||||||
MD: "Maryland",
|
|
||||||
MA: "Massachusetts",
|
|
||||||
MI: "Michigan",
|
|
||||||
MN: "Minnesota",
|
|
||||||
MS: "Mississippi",
|
|
||||||
MO: "Missouri",
|
|
||||||
MT: "Montana",
|
|
||||||
NE: "Nebraska",
|
|
||||||
NV: "Nevada",
|
|
||||||
NH: "New Hampshire",
|
|
||||||
NJ: "New Jersey",
|
|
||||||
NM: "New Mexico",
|
|
||||||
NY: "New York",
|
|
||||||
NC: "North Carolina",
|
|
||||||
ND: "North Dakota",
|
|
||||||
OH: "Ohio",
|
|
||||||
OK: "Oklahoma",
|
|
||||||
OR: "Oregon",
|
|
||||||
PA: "Pennsylvania",
|
|
||||||
RI: "Rhode Island",
|
|
||||||
SC: "South Carolina",
|
|
||||||
SD: "South Dakota",
|
|
||||||
TN: "Tennessee",
|
|
||||||
TX: "Texas",
|
|
||||||
UT: "Utah",
|
|
||||||
VT: "Vermont",
|
|
||||||
VA: "Virginia",
|
|
||||||
WA: "Washington",
|
|
||||||
WV: "West Virginia",
|
|
||||||
WI: "Wisconsin",
|
|
||||||
WY: "Wyoming",
|
|
||||||
DC: "District of Columbia",
|
|
||||||
PR: "Puerto Rico",
|
|
||||||
VI: "Virgin Islands",
|
|
||||||
AS: "American Samoa",
|
|
||||||
GU: "Guam",
|
|
||||||
MP: "Northern Mariana Islands",
|
|
||||||
};
|
|
||||||
|
|
||||||
const LocationForm: React.FC<LocationFormProps> = ({
|
const LocationForm: React.FC<LocationFormProps> = ({
|
||||||
data,
|
data,
|
||||||
userAddresses,
|
userAddresses,
|
||||||
@@ -164,11 +59,13 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
|
|
||||||
// Debounced geocoding function
|
// Debounced geocoding function
|
||||||
const geocodeAddress = useCallback(
|
const geocodeAddress = useCallback(
|
||||||
async (addressData: LocationFormData) => {
|
async (
|
||||||
|
addressData: LocationFormData
|
||||||
|
): Promise<{ latitude: number; longitude: number } | null> => {
|
||||||
if (
|
if (
|
||||||
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
||||||
) {
|
) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGeocoding(true);
|
setGeocoding(true);
|
||||||
@@ -182,6 +79,7 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
|
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
setGeocodeError(result.details || result.error);
|
setGeocodeError(result.details || result.error);
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
setGeocodeSuccess(true);
|
setGeocodeSuccess(true);
|
||||||
if (onCoordinatesChange) {
|
if (onCoordinatesChange) {
|
||||||
@@ -189,9 +87,13 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
}
|
}
|
||||||
// Clear success message after 3 seconds
|
// Clear success message after 3 seconds
|
||||||
setTimeout(() => setGeocodeSuccess(false), 3000);
|
setTimeout(() => setGeocodeSuccess(false), 3000);
|
||||||
|
|
||||||
|
// Return the coordinates
|
||||||
|
return { latitude: result.latitude, longitude: result.longitude };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setGeocodeError("Failed to geocode address");
|
setGeocodeError("Failed to geocode address");
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setGeocoding(false);
|
setGeocoding(false);
|
||||||
}
|
}
|
||||||
@@ -202,10 +104,10 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
// Expose geocoding function to parent components
|
// Expose geocoding function to parent components
|
||||||
const triggerGeocoding = useCallback(async () => {
|
const triggerGeocoding = useCallback(async () => {
|
||||||
if (data.address1 && data.city && data.state && data.zipCode) {
|
if (data.address1 && data.city && data.state && data.zipCode) {
|
||||||
await geocodeAddress(data);
|
const coordinates = await geocodeAddress(data);
|
||||||
return true; // Successfully triggered
|
return coordinates; // Return coordinates directly from geocoding
|
||||||
}
|
}
|
||||||
return false; // Incomplete address
|
return null; // Incomplete address
|
||||||
}, [data, geocodeAddress]);
|
}, [data, geocodeAddress]);
|
||||||
|
|
||||||
// Pass geocoding function to parent component
|
// Pass geocoding function to parent component
|
||||||
@@ -215,16 +117,19 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [onGeocodeRef, triggerGeocoding]);
|
}, [onGeocodeRef, triggerGeocoding]);
|
||||||
|
|
||||||
|
// Use address autocomplete hook
|
||||||
|
const { parsePlace } = useAddressAutocomplete();
|
||||||
|
|
||||||
// Handle place selection from autocomplete
|
// Handle place selection from autocomplete
|
||||||
const handlePlaceSelect = useCallback(
|
const handlePlaceSelect = useCallback(
|
||||||
(place: PlaceDetails) => {
|
(place: PlaceDetails) => {
|
||||||
try {
|
try {
|
||||||
const addressComponents = place.addressComponents;
|
const parsedAddress = parsePlace(place);
|
||||||
|
|
||||||
// Build address1 from street number and route
|
if (!parsedAddress) {
|
||||||
const streetNumber = addressComponents.streetNumber || "";
|
setPlacesApiError(true);
|
||||||
const route = addressComponents.route || "";
|
return;
|
||||||
const address1 = `${streetNumber} ${route}`.trim();
|
}
|
||||||
|
|
||||||
// Create synthetic events to update form data
|
// Create synthetic events to update form data
|
||||||
const createSyntheticEvent = (name: string, value: string) =>
|
const createSyntheticEvent = (name: string, value: string) =>
|
||||||
@@ -237,52 +142,15 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
} as React.ChangeEvent<HTMLInputElement>);
|
} as React.ChangeEvent<HTMLInputElement>);
|
||||||
|
|
||||||
// Update all address fields
|
// Update all address fields
|
||||||
onChange(
|
onChange(createSyntheticEvent("address1", parsedAddress.address1));
|
||||||
createSyntheticEvent("address1", address1 || place.formattedAddress)
|
onChange(createSyntheticEvent("city", parsedAddress.city));
|
||||||
);
|
onChange(createSyntheticEvent("state", parsedAddress.state));
|
||||||
|
onChange(createSyntheticEvent("zipCode", parsedAddress.zipCode));
|
||||||
if (addressComponents.locality) {
|
onChange(createSyntheticEvent("country", parsedAddress.country));
|
||||||
onChange(createSyntheticEvent("city", addressComponents.locality));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addressComponents.administrativeAreaLevel1) {
|
|
||||||
// Convert state code to full name using mapping, with fallback to long name or original code
|
|
||||||
const stateCode = addressComponents.administrativeAreaLevel1;
|
|
||||||
const stateName =
|
|
||||||
stateCodeToName[stateCode] ||
|
|
||||||
addressComponents.administrativeAreaLevel1Long ||
|
|
||||||
stateCode;
|
|
||||||
|
|
||||||
// Only set the state if it exists in our dropdown options
|
|
||||||
if (usStates.includes(stateName)) {
|
|
||||||
onChange(createSyntheticEvent("state", stateName));
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`State not found in dropdown options: ${stateName} (code: ${stateCode})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addressComponents.postalCode) {
|
|
||||||
onChange(
|
|
||||||
createSyntheticEvent("zipCode", addressComponents.postalCode)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addressComponents.country) {
|
|
||||||
onChange(createSyntheticEvent("country", addressComponents.country));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set coordinates immediately
|
// Set coordinates immediately
|
||||||
if (
|
if (onCoordinatesChange) {
|
||||||
onCoordinatesChange &&
|
onCoordinatesChange(parsedAddress.latitude, parsedAddress.longitude);
|
||||||
place.geometry.latitude &&
|
|
||||||
place.geometry.longitude
|
|
||||||
) {
|
|
||||||
onCoordinatesChange(
|
|
||||||
place.geometry.latitude,
|
|
||||||
place.geometry.longitude
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any previous geocoding messages
|
// Clear any previous geocoding messages
|
||||||
@@ -295,7 +163,7 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
|||||||
setPlacesApiError(true);
|
setPlacesApiError(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange, onCoordinatesChange]
|
[onChange, onCoordinatesChange, parsePlace]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle Places API errors
|
// Handle Places API errors
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ const Navbar: React.FC = () => {
|
|||||||
navigate("/");
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e?: React.FormEvent | React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (searchFilters.search.trim()) {
|
if (searchFilters.search.trim()) {
|
||||||
@@ -142,7 +142,7 @@ const Navbar: React.FC = () => {
|
|||||||
<div className="collapse navbar-collapse" id="navbarNav">
|
<div className="collapse navbar-collapse" id="navbarNav">
|
||||||
<div className="d-flex align-items-center w-100">
|
<div className="d-flex align-items-center w-100">
|
||||||
<div className="position-absolute start-50 translate-middle-x">
|
<div className="position-absolute start-50 translate-middle-x">
|
||||||
<form onSubmit={handleSearch}>
|
<div>
|
||||||
<div className="input-group" style={{ width: "520px" }}>
|
<div className="input-group" style={{ width: "520px" }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -152,6 +152,11 @@ const Navbar: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleSearchInputChange("search", e.target.value)
|
handleSearchInputChange("search", e.target.value)
|
||||||
}
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearch(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className="input-group-text text-muted"
|
className="input-group-text text-muted"
|
||||||
@@ -171,12 +176,21 @@ const Navbar: React.FC = () => {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleSearchInputChange("location", e.target.value)
|
handleSearchInputChange("location", e.target.value)
|
||||||
}
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearch(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-outline-secondary" type="submit">
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
<i className="bi bi-search"></i>
|
<i className="bi bi-search"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto d-flex align-items-center">
|
<div className="ms-auto d-flex align-items-center">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
93
frontend/src/hooks/useAddressAutocomplete.ts
Normal file
93
frontend/src/hooks/useAddressAutocomplete.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { PlaceDetails } from '../services/placesService';
|
||||||
|
|
||||||
|
// US States list
|
||||||
|
export const usStates = [
|
||||||
|
"Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado",
|
||||||
|
"Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho",
|
||||||
|
"Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana",
|
||||||
|
"Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
|
||||||
|
"Mississippi", "Missouri", "Montana", "Nebraska", "Nevada",
|
||||||
|
"New Hampshire", "New Jersey", "New Mexico", "New York",
|
||||||
|
"North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon",
|
||||||
|
"Pennsylvania", "Rhode Island", "South Carolina", "South Dakota",
|
||||||
|
"Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington",
|
||||||
|
"West Virginia", "Wisconsin", "Wyoming",
|
||||||
|
];
|
||||||
|
|
||||||
|
// State code to full name mapping
|
||||||
|
const stateCodeToName: { [key: string]: string } = {
|
||||||
|
AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas",
|
||||||
|
CA: "California", CO: "Colorado", CT: "Connecticut", DE: "Delaware",
|
||||||
|
FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho",
|
||||||
|
IL: "Illinois", IN: "Indiana", IA: "Iowa", KS: "Kansas",
|
||||||
|
KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
|
||||||
|
MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
|
||||||
|
MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
|
||||||
|
NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
|
||||||
|
NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma",
|
||||||
|
OR: "Oregon", PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina",
|
||||||
|
SD: "South Dakota", TN: "Tennessee", TX: "Texas", UT: "Utah",
|
||||||
|
VT: "Vermont", VA: "Virginia", WA: "Washington", WV: "West Virginia",
|
||||||
|
WI: "Wisconsin", WY: "Wyoming", DC: "District of Columbia",
|
||||||
|
PR: "Puerto Rico", VI: "Virgin Islands", AS: "American Samoa",
|
||||||
|
GU: "Guam", MP: "Northern Mariana Islands",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ParsedAddress {
|
||||||
|
address1: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
country: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for handling Google Places autocomplete
|
||||||
|
* Parses place details and extracts address components
|
||||||
|
*/
|
||||||
|
export const useAddressAutocomplete = () => {
|
||||||
|
const parsePlace = useCallback((place: PlaceDetails): ParsedAddress | null => {
|
||||||
|
try {
|
||||||
|
const addressComponents = place.addressComponents;
|
||||||
|
|
||||||
|
// Build address1 from street number and route
|
||||||
|
const streetNumber = addressComponents.streetNumber || "";
|
||||||
|
const route = addressComponents.route || "";
|
||||||
|
const address1 = `${streetNumber} ${route}`.trim() || place.formattedAddress;
|
||||||
|
|
||||||
|
// Parse state - convert code to full name
|
||||||
|
const stateCode = addressComponents.administrativeAreaLevel1 || "";
|
||||||
|
const stateName =
|
||||||
|
stateCodeToName[stateCode] ||
|
||||||
|
addressComponents.administrativeAreaLevel1Long ||
|
||||||
|
stateCode;
|
||||||
|
|
||||||
|
// Only use the state if it's valid
|
||||||
|
const state = usStates.includes(stateName) ? stateName : "";
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
console.warn(
|
||||||
|
`State not found in dropdown options: ${stateName} (code: ${stateCode})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
address1,
|
||||||
|
city: addressComponents.locality || "",
|
||||||
|
state,
|
||||||
|
zipCode: addressComponents.postalCode || "",
|
||||||
|
country: addressComponents.country || "US",
|
||||||
|
latitude: place.geometry.latitude,
|
||||||
|
longitude: place.geometry.longitude,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing place details:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { parsePlace };
|
||||||
|
};
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { forumAPI } from "../services/api";
|
import { forumAPI, addressAPI } from "../services/api";
|
||||||
import TagInput from "../components/TagInput";
|
import TagInput from "../components/TagInput";
|
||||||
import ForumImageUpload from "../components/ForumImageUpload";
|
import ForumImageUpload from "../components/ForumImageUpload";
|
||||||
|
import { Address } from "../types";
|
||||||
|
|
||||||
const CreateForumPost: React.FC = () => {
|
const CreateForumPost: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: "",
|
title: "",
|
||||||
@@ -20,11 +22,27 @@ const CreateForumPost: React.FC = () => {
|
|||||||
| "community_resources"
|
| "community_resources"
|
||||||
| "general_discussion",
|
| "general_discussion",
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
|
zipCode: user?.zipCode || "",
|
||||||
|
latitude: undefined as number | undefined,
|
||||||
|
longitude: undefined as number | undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
const [imageFiles, setImageFiles] = useState<File[]>([]);
|
||||||
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserAddresses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserAddresses = async () => {
|
||||||
|
try {
|
||||||
|
const response = await addressAPI.getAddresses();
|
||||||
|
setUserAddresses(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching addresses:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
value: "item_request",
|
value: "item_request",
|
||||||
@@ -54,7 +72,21 @@ const CreateForumPost: React.FC = () => {
|
|||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
|
||||||
|
// If category is being changed to item_request and user has addresses, autopopulate location data
|
||||||
|
if (name === "category" && value === "item_request" && userAddresses.length > 0) {
|
||||||
|
// Try to find primary address first, otherwise use first address
|
||||||
|
const primaryAddress = userAddresses.find(addr => addr.isPrimary) || userAddresses[0];
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value,
|
||||||
|
zipCode: primaryAddress.zipCode,
|
||||||
|
latitude: primaryAddress.latitude,
|
||||||
|
longitude: primaryAddress.longitude
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTagsChange = (tags: string[]) => {
|
const handleTagsChange = (tags: string[]) => {
|
||||||
@@ -111,6 +143,11 @@ const CreateForumPost: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formData.category === "item_request" && !formData.zipCode.trim()) {
|
||||||
|
setError("Zip code is required for item requests");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
@@ -125,6 +162,16 @@ const CreateForumPost: React.FC = () => {
|
|||||||
submitData.append('tags', JSON.stringify(formData.tags));
|
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);
|
||||||
|
// If we have coordinates from a saved address, send them to avoid re-geocoding
|
||||||
|
if (formData.latitude !== undefined && formData.longitude !== undefined) {
|
||||||
|
submitData.append('latitude', formData.latitude.toString());
|
||||||
|
submitData.append('longitude', formData.longitude.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add images
|
// Add images
|
||||||
imageFiles.forEach((file) => {
|
imageFiles.forEach((file) => {
|
||||||
submitData.append('images', file);
|
submitData.append('images', file);
|
||||||
@@ -247,6 +294,31 @@ const CreateForumPost: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Content */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="content" className="form-label">
|
<label htmlFor="content" className="form-label">
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ const CreateItem: React.FC = () => {
|
|||||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||||
const [addressesLoading, setAddressesLoading] = useState(true);
|
const [addressesLoading, setAddressesLoading] = useState(true);
|
||||||
const [selectedPricingUnit, setSelectedPricingUnit] = useState<string>("day");
|
const [selectedPricingUnit, setSelectedPricingUnit] = useState<string>("day");
|
||||||
const [showAdvancedPricing, setShowAdvancedPricing] = useState<boolean>(false);
|
const [showAdvancedPricing, setShowAdvancedPricing] =
|
||||||
|
useState<boolean>(false);
|
||||||
const [enabledPricingTiers, setEnabledPricingTiers] = useState({
|
const [enabledPricingTiers, setEnabledPricingTiers] = useState({
|
||||||
hour: false,
|
hour: false,
|
||||||
day: false,
|
day: false,
|
||||||
@@ -92,7 +93,9 @@ const CreateItem: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reference to LocationForm geocoding function
|
// Reference to LocationForm geocoding function
|
||||||
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
|
const geocodeLocationRef = useRef<
|
||||||
|
(() => Promise<{ latitude: number; longitude: number } | null>) | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserAddresses();
|
fetchUserAddresses();
|
||||||
@@ -161,11 +164,15 @@ const CreateItem: React.FC = () => {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
// Try to geocode the address before submitting
|
// Try to geocode the address before submitting
|
||||||
|
let geocodedCoordinates = null;
|
||||||
if (geocodeLocationRef.current) {
|
if (geocodeLocationRef.current) {
|
||||||
try {
|
try {
|
||||||
await geocodeLocationRef.current();
|
geocodedCoordinates = await geocodeLocationRef.current();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Geocoding failed, creating item without coordinates:', error);
|
console.warn(
|
||||||
|
"Geocoding failed, creating item without coordinates:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +195,9 @@ const CreateItem: React.FC = () => {
|
|||||||
|
|
||||||
const response = await api.post("/items", {
|
const response = await api.post("/items", {
|
||||||
...formData,
|
...formData,
|
||||||
|
// Use geocoded coordinates if available, otherwise fall back to formData
|
||||||
|
latitude: geocodedCoordinates?.latitude ?? formData.latitude,
|
||||||
|
longitude: geocodedCoordinates?.longitude ?? formData.longitude,
|
||||||
pricePerDay: formData.pricePerDay
|
pricePerDay: formData.pricePerDay
|
||||||
? parseFloat(formData.pricePerDay.toString())
|
? parseFloat(formData.pricePerDay.toString())
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -221,8 +231,8 @@ const CreateItem: React.FC = () => {
|
|||||||
state: formData.state,
|
state: formData.state,
|
||||||
zipCode: formData.zipCode,
|
zipCode: formData.zipCode,
|
||||||
country: formData.country,
|
country: formData.country,
|
||||||
latitude: formData.latitude,
|
latitude: geocodedCoordinates?.latitude ?? formData.latitude,
|
||||||
longitude: formData.longitude,
|
longitude: geocodedCoordinates?.longitude ?? formData.longitude,
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
});
|
});
|
||||||
} catch (addressError) {
|
} catch (addressError) {
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ const EditItem: React.FC = () => {
|
|||||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||||
const [addressesLoading, setAddressesLoading] = useState(true);
|
const [addressesLoading, setAddressesLoading] = useState(true);
|
||||||
const [selectedPricingUnit, setSelectedPricingUnit] = useState<string>("day");
|
const [selectedPricingUnit, setSelectedPricingUnit] = useState<string>("day");
|
||||||
const [showAdvancedPricing, setShowAdvancedPricing] = useState<boolean>(false);
|
const [showAdvancedPricing, setShowAdvancedPricing] =
|
||||||
|
useState<boolean>(false);
|
||||||
const [enabledPricingTiers, setEnabledPricingTiers] = useState({
|
const [enabledPricingTiers, setEnabledPricingTiers] = useState({
|
||||||
hour: false,
|
hour: false,
|
||||||
day: false,
|
day: false,
|
||||||
@@ -68,7 +69,9 @@ const EditItem: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reference to LocationForm geocoding function
|
// Reference to LocationForm geocoding function
|
||||||
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
|
const geocodeLocationRef = useRef<
|
||||||
|
(() => Promise<{ latitude: number; longitude: number } | null>) | null
|
||||||
|
>(null);
|
||||||
const [formData, setFormData] = useState<ItemFormData>({
|
const [formData, setFormData] = useState<ItemFormData>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -255,20 +258,29 @@ const EditItem: React.FC = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Try to geocode the address before submitting
|
// Try to geocode the address before submitting
|
||||||
|
let geocodedCoordinates = null;
|
||||||
if (geocodeLocationRef.current) {
|
if (geocodeLocationRef.current) {
|
||||||
try {
|
try {
|
||||||
await geocodeLocationRef.current();
|
geocodedCoordinates = await geocodeLocationRef.current();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Geocoding failed, updating item without coordinates:', error);
|
console.warn(
|
||||||
|
"Geocoding failed, updating item without coordinates:",
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("No geocoding function available");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use existing image previews (which includes both old and new images)
|
// Use existing image previews (which includes both old and new images)
|
||||||
const imageUrls = imagePreviews;
|
const imageUrls = imagePreviews;
|
||||||
|
|
||||||
await itemAPI.updateItem(id!, {
|
const updatePayload = {
|
||||||
...formData,
|
...formData,
|
||||||
|
// Use geocoded coordinates if available, otherwise fall back to formData
|
||||||
|
latitude: geocodedCoordinates?.latitude ?? formData.latitude,
|
||||||
|
longitude: geocodedCoordinates?.longitude ?? formData.longitude,
|
||||||
pricePerDay: formData.pricePerDay
|
pricePerDay: formData.pricePerDay
|
||||||
? parseFloat(formData.pricePerDay.toString())
|
? parseFloat(formData.pricePerDay.toString())
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -289,7 +301,9 @@ const EditItem: React.FC = () => {
|
|||||||
specifyTimesPerDay: formData.specifyTimesPerDay,
|
specifyTimesPerDay: formData.specifyTimesPerDay,
|
||||||
weeklyTimes: formData.weeklyTimes,
|
weeklyTimes: formData.weeklyTimes,
|
||||||
images: imageUrls,
|
images: imageUrls,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
await itemAPI.updateItem(id!, updatePayload);
|
||||||
|
|
||||||
// Check if user has other items - only save to user profile if no other items
|
// Check if user has other items - only save to user profile if no other items
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
geocodingService,
|
geocodingService,
|
||||||
AddressComponents,
|
AddressComponents,
|
||||||
} from "../services/geocodingService";
|
} from "../services/geocodingService";
|
||||||
|
import AddressAutocomplete from "../components/AddressAutocomplete";
|
||||||
|
import { PlaceDetails } from "../services/placesService";
|
||||||
|
import { useAddressAutocomplete, usStates } from "../hooks/useAddressAutocomplete";
|
||||||
|
|
||||||
const Profile: React.FC = () => {
|
const Profile: React.FC = () => {
|
||||||
const { user, updateUser, logout } = useAuth();
|
const { user, updateUser, logout } = useAuth();
|
||||||
@@ -34,6 +37,7 @@ const Profile: React.FC = () => {
|
|||||||
zipCode: "",
|
zipCode: "",
|
||||||
country: "",
|
country: "",
|
||||||
profileImage: "",
|
profileImage: "",
|
||||||
|
itemRequestNotificationRadius: 10,
|
||||||
});
|
});
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
@@ -137,6 +141,7 @@ 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,
|
||||||
});
|
});
|
||||||
if (response.data.profileImage) {
|
if (response.data.profileImage) {
|
||||||
setImagePreview(getImageUrl(response.data.profileImage));
|
setImagePreview(getImageUrl(response.data.profileImage));
|
||||||
@@ -259,7 +264,7 @@ const Profile: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
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 }));
|
||||||
@@ -356,6 +361,7 @@ 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,
|
||||||
});
|
});
|
||||||
setImagePreview(
|
setImagePreview(
|
||||||
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
|
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
|
||||||
@@ -395,6 +401,52 @@ const Profile: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveNotificationPreferences = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await userAPI.updateProfile({
|
||||||
|
itemRequestNotificationRadius: formData.itemRequestNotificationRadius,
|
||||||
|
});
|
||||||
|
setProfileData(response.data);
|
||||||
|
updateUser(response.data);
|
||||||
|
setSuccess("Notification preferences saved successfully");
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationRadiusChange = async (
|
||||||
|
e: React.ChangeEvent<HTMLSelectElement>
|
||||||
|
) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, itemRequestNotificationRadius: parseInt(value) }));
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await userAPI.updateProfile({
|
||||||
|
itemRequestNotificationRadius: parseInt(value),
|
||||||
|
});
|
||||||
|
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 radius";
|
||||||
|
setError(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatAddressDisplay = (address: Address) => {
|
const formatAddressDisplay = (address: Address) => {
|
||||||
return `${address.address1}, ${address.city}, ${address.state} ${address.zipCode}`;
|
return `${address.address1}, ${address.city}, ${address.state} ${address.zipCode}`;
|
||||||
};
|
};
|
||||||
@@ -452,7 +504,7 @@ const Profile: React.FC = () => {
|
|||||||
if (
|
if (
|
||||||
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
||||||
) {
|
) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAddressGeocoding(true);
|
setAddressGeocoding(true);
|
||||||
@@ -466,6 +518,7 @@ const Profile: React.FC = () => {
|
|||||||
|
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
setAddressGeocodeError(result.details || result.error);
|
setAddressGeocodeError(result.details || result.error);
|
||||||
|
return null;
|
||||||
} else {
|
} else {
|
||||||
setAddressGeocodeSuccess(true);
|
setAddressGeocodeSuccess(true);
|
||||||
setAddressFormData((prev) => ({
|
setAddressFormData((prev) => ({
|
||||||
@@ -475,9 +528,11 @@ const Profile: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
// Clear success message after 3 seconds
|
// Clear success message after 3 seconds
|
||||||
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
||||||
|
return { latitude: result.latitude, longitude: result.longitude };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAddressGeocodeError("Failed to geocode address");
|
setAddressGeocodeError("Failed to geocode address");
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setAddressGeocoding(false);
|
setAddressGeocoding(false);
|
||||||
}
|
}
|
||||||
@@ -485,12 +540,13 @@ const Profile: React.FC = () => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveAddress = async (e: React.FormEvent) => {
|
const handleSaveAddress = async (e?: React.FormEvent | React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
// Try to geocode the address before saving
|
// Try to geocode the address before saving
|
||||||
|
let coordinates = null;
|
||||||
try {
|
try {
|
||||||
await geocodeAddressForm(addressFormData);
|
coordinates = await geocodeAddressForm(addressFormData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Geocoding failed, but we'll continue with saving
|
// Geocoding failed, but we'll continue with saving
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -499,12 +555,21 @@ const Profile: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare the data to save, including coordinates if geocoding succeeded
|
||||||
|
const dataToSave = {
|
||||||
|
...addressFormData,
|
||||||
|
...(coordinates && {
|
||||||
|
latitude: coordinates.latitude,
|
||||||
|
longitude: coordinates.longitude
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingAddressId) {
|
if (editingAddressId) {
|
||||||
// Update existing address
|
// Update existing address
|
||||||
const response = await addressAPI.updateAddress(
|
const response = await addressAPI.updateAddress(
|
||||||
editingAddressId,
|
editingAddressId,
|
||||||
addressFormData
|
dataToSave
|
||||||
);
|
);
|
||||||
setUserAddresses((prev) =>
|
setUserAddresses((prev) =>
|
||||||
prev.map((addr) =>
|
prev.map((addr) =>
|
||||||
@@ -514,7 +579,7 @@ const Profile: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
// Create new address
|
// Create new address
|
||||||
const response = await addressAPI.createAddress({
|
const response = await addressAPI.createAddress({
|
||||||
...addressFormData,
|
...dataToSave,
|
||||||
isPrimary: userAddresses.length === 0,
|
isPrimary: userAddresses.length === 0,
|
||||||
});
|
});
|
||||||
setUserAddresses((prev) => [...prev, response.data]);
|
setUserAddresses((prev) => [...prev, response.data]);
|
||||||
@@ -545,58 +610,21 @@ const Profile: React.FC = () => {
|
|||||||
setAddressGeocodeSuccess(false);
|
setAddressGeocodeSuccess(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const usStates = [
|
// Use address autocomplete hook
|
||||||
"Alabama",
|
const { parsePlace } = useAddressAutocomplete();
|
||||||
"Alaska",
|
|
||||||
"Arizona",
|
// Handle place selection from autocomplete
|
||||||
"Arkansas",
|
const handlePlaceSelect = useCallback((place: PlaceDetails) => {
|
||||||
"California",
|
const parsedAddress = parsePlace(place);
|
||||||
"Colorado",
|
if (parsedAddress) {
|
||||||
"Connecticut",
|
setAddressFormData((prev) => ({
|
||||||
"Delaware",
|
...prev,
|
||||||
"Florida",
|
...parsedAddress,
|
||||||
"Georgia",
|
}));
|
||||||
"Hawaii",
|
setAddressGeocodeSuccess(true);
|
||||||
"Idaho",
|
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
||||||
"Illinois",
|
}
|
||||||
"Indiana",
|
}, [parsePlace]);
|
||||||
"Iowa",
|
|
||||||
"Kansas",
|
|
||||||
"Kentucky",
|
|
||||||
"Louisiana",
|
|
||||||
"Maine",
|
|
||||||
"Maryland",
|
|
||||||
"Massachusetts",
|
|
||||||
"Michigan",
|
|
||||||
"Minnesota",
|
|
||||||
"Mississippi",
|
|
||||||
"Missouri",
|
|
||||||
"Montana",
|
|
||||||
"Nebraska",
|
|
||||||
"Nevada",
|
|
||||||
"New Hampshire",
|
|
||||||
"New Jersey",
|
|
||||||
"New Mexico",
|
|
||||||
"New York",
|
|
||||||
"North Carolina",
|
|
||||||
"North Dakota",
|
|
||||||
"Ohio",
|
|
||||||
"Oklahoma",
|
|
||||||
"Oregon",
|
|
||||||
"Pennsylvania",
|
|
||||||
"Rhode Island",
|
|
||||||
"South Carolina",
|
|
||||||
"South Dakota",
|
|
||||||
"Tennessee",
|
|
||||||
"Texas",
|
|
||||||
"Utah",
|
|
||||||
"Vermont",
|
|
||||||
"Virginia",
|
|
||||||
"Washington",
|
|
||||||
"West Virginia",
|
|
||||||
"Wisconsin",
|
|
||||||
"Wyoming",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -649,6 +677,15 @@ const Profile: React.FC = () => {
|
|||||||
<i className="bi bi-gear me-2"></i>
|
<i className="bi bi-gear me-2"></i>
|
||||||
Owner Settings
|
Owner Settings
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`list-group-item list-group-item-action ${
|
||||||
|
activeSection === "notification-preferences" ? "active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveSection("notification-preferences")}
|
||||||
|
>
|
||||||
|
<i className="bi bi-bell me-2"></i>
|
||||||
|
Notification Preferences
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`list-group-item list-group-item-action ${
|
className={`list-group-item list-group-item-action ${
|
||||||
activeSection === "rental-history" ? "active" : ""
|
activeSection === "rental-history" ? "active" : ""
|
||||||
@@ -679,112 +716,59 @@ const Profile: React.FC = () => {
|
|||||||
{/* Profile Card */}
|
{/* Profile Card */}
|
||||||
<div className="card mb-4">
|
<div className="card mb-4">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form onSubmit={handleSubmit}>
|
<div className="text-center">
|
||||||
<div className="text-center">
|
<div className="position-relative d-inline-block mb-3">
|
||||||
<div className="position-relative d-inline-block mb-3">
|
{imagePreview ? (
|
||||||
{imagePreview ? (
|
<img
|
||||||
<img
|
src={imagePreview}
|
||||||
src={imagePreview}
|
alt="Profile"
|
||||||
alt="Profile"
|
className="rounded-circle"
|
||||||
className="rounded-circle"
|
style={{
|
||||||
style={{
|
width: "120px",
|
||||||
width: "120px",
|
height: "120px",
|
||||||
height: "120px",
|
objectFit: "cover",
|
||||||
objectFit: "cover",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
|
||||||
style={{ width: "120px", height: "120px" }}
|
|
||||||
>
|
|
||||||
<i
|
|
||||||
className="bi bi-person-fill text-white"
|
|
||||||
style={{ fontSize: "2.5rem" }}
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editing && (
|
|
||||||
<label
|
|
||||||
htmlFor="profileImageOverview"
|
|
||||||
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
|
||||||
style={{
|
|
||||||
width: "35px",
|
|
||||||
height: "35px",
|
|
||||||
padding: "0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="bi bi-camera-fill"></i>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
id="profileImageOverview"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageChange}
|
|
||||||
className="d-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{editing ? (
|
|
||||||
<div>
|
|
||||||
<div className="row justify-content-center mb-3">
|
|
||||||
<div className="col-md-6">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control mb-2"
|
|
||||||
name="firstName"
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="First Name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-md-6">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-control mb-2"
|
|
||||||
name="lastName"
|
|
||||||
value={formData.lastName}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Last Name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="d-flex gap-2 justify-content-center">
|
|
||||||
<button type="submit" className="btn btn-primary">
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div
|
||||||
<h5>
|
className="rounded-circle bg-secondary d-flex align-items-center justify-content-center"
|
||||||
{profileData?.firstName} {profileData?.lastName}
|
style={{ width: "120px", height: "120px" }}
|
||||||
</h5>
|
>
|
||||||
<p className="text-muted">@{profileData?.username}</p>
|
<i
|
||||||
<div>
|
className="bi bi-person-fill text-white"
|
||||||
<button
|
style={{ fontSize: "2.5rem" }}
|
||||||
type="button"
|
></i>
|
||||||
className="btn btn-outline-primary"
|
|
||||||
onClick={() => setEditing(true)}
|
|
||||||
>
|
|
||||||
<i className="bi bi-pencil me-2"></i>
|
|
||||||
Edit Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{editing && (
|
||||||
|
<label
|
||||||
|
htmlFor="profileImageOverview"
|
||||||
|
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
|
||||||
|
style={{
|
||||||
|
width: "35px",
|
||||||
|
height: "35px",
|
||||||
|
padding: "0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-camera-fill"></i>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="profileImageOverview"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="d-none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<div>
|
||||||
|
<h5>
|
||||||
|
{profileData?.firstName} {profileData?.lastName}
|
||||||
|
</h5>
|
||||||
|
<p className="text-muted">@{profileData?.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -804,6 +788,39 @@ const Profile: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{showPersonalInfo && (
|
{showPersonalInfo && (
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<label htmlFor="firstName" className="form-label">
|
||||||
|
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">
|
<div className="mb-3">
|
||||||
<label htmlFor="email" className="form-label">
|
<label htmlFor="email" className="form-label">
|
||||||
Email
|
Email
|
||||||
@@ -835,6 +852,241 @@ const Profile: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
||||||
|
<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 ? (
|
{editing ? (
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<button type="submit" className="btn btn-primary">
|
<button type="submit" className="btn btn-primary">
|
||||||
@@ -1180,219 +1432,6 @@ const Profile: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="mb-4">Owner Settings</h4>
|
<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 */}
|
{/* Availability Card */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
@@ -1412,6 +1451,39 @@ const Profile: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Notification Preferences Section */}
|
||||||
|
{activeSection === "notification-preferences" && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4">Notification Preferences</h4>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
<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={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 when someone posts an item request within this distance from your primary address
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface User {
|
|||||||
role?: "user" | "admin";
|
role?: "user" | "admin";
|
||||||
stripeConnectedAccountId?: string;
|
stripeConnectedAccountId?: string;
|
||||||
addresses?: Address[];
|
addresses?: Address[];
|
||||||
|
itemRequestNotificationRadius?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|||||||
Reference in New Issue
Block a user