fixing bugs with item notification radius

This commit is contained in:
jackiettran
2025-11-20 15:01:15 -05:00
parent 413ac6b6e2
commit 83872fe039
11 changed files with 842 additions and 680 deletions

View File

@@ -240,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, zipCode } = 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') {
@@ -258,26 +258,53 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
let latitude = null; let latitude = null;
let longitude = null; let longitude = null;
// Geocode zip code for item requests // Use provided coordinates if available, otherwise geocode zip code
if (category === 'item_request' && zipCode) { if (category === 'item_request' && zipCode) {
try { // If coordinates were provided from a saved address, use them directly
const geocodeResult = await googleMapsService.geocodeAddress(zipCode); if (providedLat && providedLng) {
latitude = geocodeResult.latitude; latitude = parseFloat(providedLat);
longitude = geocodeResult.longitude; longitude = parseFloat(providedLng);
const reqLogger = logger.withRequestId(req.id); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Geocoded zip code for item request", { reqLogger.info("Using provided coordinates for item request", {
zipCode, zipCode,
latitude, latitude,
longitude longitude,
source: 'saved_address'
}); });
} catch (error) { } else {
const reqLogger = logger.withRequestId(req.id); // Otherwise, geocode the zip code
reqLogger.error("Geocoding failed for item request", { try {
error: error.message, const geocodeResult = await googleMapsService.geocodeAddress(zipCode);
zipCode
}); // Check if geocoding was successful
// Continue without coordinates - post will still be created 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
}
} }
} }
@@ -332,6 +359,13 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
if (category === 'item_request' && latitude && longitude) { if (category === 'item_request' && latitude && longitude) {
(async () => { (async () => {
try { try {
logger.info("Starting item request notifications", {
postId: post.id,
latitude,
longitude,
zipCode
});
// Find all users within maximum radius (100 miles) // Find all users within maximum radius (100 miles)
const nearbyUsers = await locationService.findUsersInRadius( const nearbyUsers = await locationService.findUsersInRadius(
latitude, latitude,
@@ -339,10 +373,17 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
100 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); const postAuthor = await User.findByPk(req.user.id);
let notificationsSent = 0; let notificationsSent = 0;
let usersChecked = 0; let usersChecked = 0;
let usersSkipped = 0;
for (const user of nearbyUsers) { for (const user of nearbyUsers) {
// Don't notify the requester // Don't notify the requester
@@ -356,6 +397,17 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
const userPreferredRadius = userProfile?.itemRequestNotificationRadius || 10; 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 // Only notify if within user's preferred radius
if (parseFloat(user.distance) <= userPreferredRadius) { if (parseFloat(user.distance) <= userPreferredRadius) {
try { try {
@@ -366,6 +418,11 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
user.distance user.distance
); );
notificationsSent++; notificationsSent++;
logger.info("Sent notification to user", {
postId: post.id,
userId: user.id,
distance: user.distance
});
} catch (emailError) { } catch (emailError) {
logger.error("Failed to send item request notification", { logger.error("Failed to send item request notification", {
error: emailError.message, error: emailError.message,
@@ -373,14 +430,17 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
postId: post.id postId: post.id
}); });
} }
} else {
usersSkipped++;
} }
} }
} }
logger.info("Item request notifications sent", { logger.info("Item request notifications complete", {
postId: post.id, postId: post.id,
totalNearbyUsers: nearbyUsers.length, totalNearbyUsers: nearbyUsers.length,
usersChecked, usersChecked,
usersSkipped,
notificationsSent notificationsSent
}); });
} catch (error) { } catch (error) {
@@ -391,6 +451,13 @@ router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res)
}); });
} }
})(); })();
} 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);

View File

@@ -20,6 +20,12 @@ class LocationService {
throw new Error('Radius must be between 1 and 100 miles'); throw new Error('Radius must be between 1 and 100 miles');
} }
console.log('Finding users in radius:', {
centerLatitude: latitude,
centerLongitude: longitude,
radiusMiles
});
try { try {
// Haversine formula: // Haversine formula:
// distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2)) // distance = 3959 * acos(cos(radians(lat1)) * cos(radians(lat2))
@@ -27,26 +33,28 @@ class LocationService {
// + sin(radians(lat1)) * sin(radians(lat2))) // + sin(radians(lat1)) * sin(radians(lat2)))
// Note: 3959 is Earth's radius in miles // Note: 3959 is Earth's radius in miles
const query = ` const query = `
SELECT SELECT * FROM (
u.id, SELECT
u.email, u.id,
u."firstName", u.email,
u."lastName", u."firstName",
ua.latitude, u."lastName",
ua.longitude, ua.latitude,
(3959 * acos( ua.longitude,
LEAST(1.0, (3959 * acos(
cos(radians(:lat)) * cos(radians(ua.latitude)) LEAST(1.0,
* cos(radians(ua.longitude) - radians(:lng)) cos(radians(:lat)) * cos(radians(ua.latitude))
+ sin(radians(:lat)) * sin(radians(ua.latitude)) * cos(radians(ua.longitude) - radians(:lng))
) + sin(radians(:lat)) * sin(radians(ua.latitude))
)) AS distance )
FROM "Users" u )) AS distance
INNER JOIN "UserAddresses" ua ON u.id = ua."userId" FROM "Users" u
WHERE ua."isPrimary" = true INNER JOIN "UserAddresses" ua ON u.id = ua."userId"
AND ua.latitude IS NOT NULL WHERE ua."isPrimary" = true
AND ua.longitude IS NOT NULL AND ua.latitude IS NOT NULL
HAVING distance < :radiusMiles AND ua.longitude IS NOT NULL
) AS user_distances
WHERE distance < :radiusMiles
ORDER BY distance ASC ORDER BY distance ASC
`; `;
@@ -59,6 +67,13 @@ class LocationService {
type: QueryTypes.SELECT 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 => ({ return users.map(user => ({
id: user.id, id: user.id,
email: user.email, email: user.email,

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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: "",
@@ -21,11 +23,26 @@ const CreateForumPost: React.FC = () => {
| "general_discussion", | "general_discussion",
tags: [] as string[], tags: [] as string[],
zipCode: user?.zipCode || "", 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",
@@ -55,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[]) => {
@@ -134,6 +165,11 @@ const CreateForumPost: React.FC = () => {
// Add location data for item requests // Add location data for item requests
if (formData.category === 'item_request' && formData.zipCode) { if (formData.category === 'item_request' && formData.zipCode) {
submitData.append('zipCode', 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

View File

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

View File

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

View File

@@ -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();
@@ -398,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}`;
}; };
@@ -455,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);
@@ -469,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) => ({
@@ -478,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);
} }
@@ -488,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(
@@ -502,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) =>
@@ -517,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]);
@@ -548,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 (
@@ -652,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" : ""
@@ -682,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>
@@ -807,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
@@ -931,7 +945,7 @@ const Profile: React.FC = () => {
{/* Address Form */} {/* Address Form */}
{showAddressForm && ( {showAddressForm && (
<form onSubmit={handleSaveAddress}> <div>
<div className="row mb-3"> <div className="row mb-3">
<div className="col-md-6"> <div className="col-md-6">
<label <label
@@ -940,15 +954,26 @@ const Profile: React.FC = () => {
> >
Address Line 1 * Address Line 1 *
</label> </label>
<input <AddressAutocomplete
type="text"
className="form-control"
id="addressFormAddress1" id="addressFormAddress1"
name="address1" name="address1"
value={addressFormData.address1} value={addressFormData.address1}
onChange={handleAddressFormChange} onChange={(value) => {
placeholder="" 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 required
countryRestriction="us"
types={["address"]}
/> />
</div> </div>
<div className="col-md-6"> <div className="col-md-6">
@@ -1025,6 +1050,11 @@ const Profile: React.FC = () => {
name="zipCode" name="zipCode"
value={addressFormData.zipCode} value={addressFormData.zipCode}
onChange={handleAddressFormChange} onChange={handleAddressFormChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSaveAddress(e);
}
}}
placeholder="12345" placeholder="12345"
required required
/> />
@@ -1032,7 +1062,11 @@ const Profile: React.FC = () => {
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<button type="submit" className="btn btn-primary"> <button
type="button"
className="btn btn-primary"
onClick={handleSaveAddress}
>
{editingAddressId {editingAddressId
? "Update Address" ? "Update Address"
: "Save Address"} : "Save Address"}
@@ -1045,7 +1079,7 @@ const Profile: React.FC = () => {
Cancel Cancel
</button> </button>
</div> </div>
</form> </div>
)} )}
</> </>
)} )}
@@ -1053,36 +1087,6 @@ const Profile: React.FC = () => {
<hr className="my-4" /> <hr className="my-4" />
{/* Notification Preferences Section */}
<div className="mb-3">
<label className="form-label">Notification Preferences</label>
<div className="mb-3">
<label htmlFor="itemRequestNotificationRadius" className="form-label">
Item Requests Notification Radius
</label>
<select
className="form-select"
id="itemRequestNotificationRadius"
name="itemRequestNotificationRadius"
value={formData.itemRequestNotificationRadius}
onChange={handleChange}
disabled={!editing}
>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
<option value="25">25 miles</option>
<option value="50">50 miles</option>
<option value="100">100 miles</option>
</select>
<div className="form-text">
You'll receive notifications when someone posts an item request within this distance from your primary address
</div>
</div>
</div>
<hr className="my-4" />
{editing ? ( {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">
@@ -1447,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>