Compare commits
2 Commits
bbab991e31
...
1d7db138df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7db138df | ||
|
|
69bf64fe70 |
119
backend/middleware/rateLimiter.js
Normal file
119
backend/middleware/rateLimiter.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const rateLimit = require("express-rate-limit");
|
||||
|
||||
// General rate limiter for Maps API endpoints
|
||||
const createMapsRateLimiter = (windowMs, max, message) => {
|
||||
return rateLimit({
|
||||
windowMs, // time window in milliseconds
|
||||
max, // limit each IP/user to max requests per windowMs
|
||||
message: {
|
||||
error: message,
|
||||
retryAfter: Math.ceil(windowMs / 1000), // seconds
|
||||
},
|
||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||
// Use user ID if available, otherwise fall back to IPv6-safe IP handling
|
||||
keyGenerator: (req) => {
|
||||
if (req.user?.id) {
|
||||
return `user:${req.user.id}`;
|
||||
}
|
||||
// Use the built-in IP key generator which properly handles IPv6
|
||||
return rateLimit.defaultKeyGenerator(req);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Specific rate limiters for different endpoints
|
||||
const rateLimiters = {
|
||||
// Places Autocomplete - allow more requests since users type frequently
|
||||
placesAutocomplete: createMapsRateLimiter(
|
||||
60 * 1000, // 1 minute window
|
||||
30, // 30 requests per minute per user/IP
|
||||
"Too many autocomplete requests. Please slow down."
|
||||
),
|
||||
|
||||
// Place Details - moderate limit since each selection triggers this
|
||||
placeDetails: createMapsRateLimiter(
|
||||
60 * 1000, // 1 minute window
|
||||
20, // 20 requests per minute per user/IP
|
||||
"Too many place detail requests. Please slow down."
|
||||
),
|
||||
|
||||
// Geocoding - lower limit since this is typically used less frequently
|
||||
geocoding: createMapsRateLimiter(
|
||||
60 * 1000, // 1 minute window
|
||||
10, // 10 requests per minute per user/IP
|
||||
"Too many geocoding requests. Please slow down."
|
||||
),
|
||||
};
|
||||
|
||||
// Enhanced rate limiter with user-specific limits
|
||||
const createUserBasedRateLimiter = (windowMs, max, message) => {
|
||||
const store = new Map(); // Simple in-memory store (use Redis in production)
|
||||
|
||||
return (req, res, next) => {
|
||||
const key = req.user?.id
|
||||
? `user:${req.user.id}`
|
||||
: rateLimit.defaultKeyGenerator(req);
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
// Clean up old entries
|
||||
for (const [k, data] of store.entries()) {
|
||||
if (data.windowStart < windowStart) {
|
||||
store.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
// Get or create user's request data
|
||||
let userData = store.get(key);
|
||||
if (!userData || userData.windowStart < windowStart) {
|
||||
userData = {
|
||||
count: 0,
|
||||
windowStart: now,
|
||||
resetTime: now + windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (userData.count >= max) {
|
||||
return res.status(429).json({
|
||||
error: message,
|
||||
retryAfter: Math.ceil((userData.resetTime - now) / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
// Increment counter and store
|
||||
userData.count++;
|
||||
store.set(key, userData);
|
||||
|
||||
// Add headers
|
||||
res.set({
|
||||
"RateLimit-Limit": max,
|
||||
"RateLimit-Remaining": Math.max(0, max - userData.count),
|
||||
"RateLimit-Reset": new Date(userData.resetTime).toISOString(),
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Burst protection for expensive operations
|
||||
const burstProtection = createUserBasedRateLimiter(
|
||||
10 * 1000, // 10 seconds
|
||||
5, // 5 requests per 10 seconds
|
||||
"Too many requests in a short period. Please slow down."
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
// Individual rate limiters
|
||||
placesAutocomplete: rateLimiters.placesAutocomplete,
|
||||
placeDetails: rateLimiters.placeDetails,
|
||||
geocoding: rateLimiters.geocoding,
|
||||
|
||||
// Burst protection
|
||||
burstProtection,
|
||||
|
||||
// Utility functions
|
||||
createMapsRateLimiter,
|
||||
createUserBasedRateLimiter,
|
||||
};
|
||||
275
backend/package-lock.json
generated
275
backend/package-lock.json
generated
@@ -9,11 +9,13 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@googlemaps/google-maps-services-js": "^3.4.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"body-parser": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
@@ -27,6 +29,28 @@
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@googlemaps/google-maps-services-js": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@googlemaps/google-maps-services-js/-/google-maps-services-js-3.4.2.tgz",
|
||||
"integrity": "sha512-QjxiJSt8woyPaaQIUUDNL1nRfCEXTiv8KfJSNq/YzKEUVXABT75GLG+Zo267UwOcq70n8OsZbaro5VrY962mJg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@googlemaps/url-signature": "^1.0.4",
|
||||
"agentkeepalive": "^4.1.0",
|
||||
"axios": "^1.5.1",
|
||||
"query-string": "<8.x",
|
||||
"retry-axios": "<3.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@googlemaps/url-signature": {
|
||||
"version": "1.0.40",
|
||||
"resolved": "https://registry.npmjs.org/@googlemaps/url-signature/-/url-signature-1.0.40.tgz",
|
||||
"integrity": "sha512-Gme3JxGZWQ4NVpATajSpS2/inQzhUxRvr/FK6IFpcC7AHOAmx8blI0y1/Qi2jqil+WoQ3TkEqq/MaKVtuV68RQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -103,6 +127,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agentkeepalive": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
||||
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"humanize-ms": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
@@ -144,6 +180,12 @@
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||
@@ -152,6 +194,17 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -395,6 +448,18 @@
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
@@ -493,6 +558,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -509,6 +580,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decode-uri-component": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
||||
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -621,6 +710,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -683,6 +787,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.1.0.tgz",
|
||||
"integrity": "sha512-4nLnATuKupnmwqiJc27b4dCFmB/T60ExgmtDD7waf4LdrbJ8CPZzZRHYErDYNhoz+ql8fUdYwM/opf90PoPAQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -695,6 +817,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/filter-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
|
||||
@@ -711,6 +842,26 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -726,6 +877,43 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -902,6 +1090,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -936,6 +1139,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/humanize-ms": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -971,6 +1183,15 @@
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1704,6 +1925,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pstree.remy": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
|
||||
@@ -1724,6 +1951,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
|
||||
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.2.2",
|
||||
"filter-obj": "^1.1.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -1804,6 +2049,18 @@
|
||||
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||
"integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw=="
|
||||
},
|
||||
"node_modules/retry-axios": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-2.6.0.tgz",
|
||||
"integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
@@ -2103,6 +2360,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
@@ -2127,6 +2393,15 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-uri-encode": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
||||
@@ -16,11 +16,13 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@googlemaps/google-maps-services-js": "^3.4.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"body-parser": "^2.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.3",
|
||||
|
||||
@@ -48,8 +48,20 @@ router.get("/", async (req, res) => {
|
||||
order: [["createdAt", "DESC"]],
|
||||
});
|
||||
|
||||
// Round coordinates to 2 decimal places for map display while keeping precise values in database
|
||||
const itemsWithRoundedCoords = rows.map(item => {
|
||||
const itemData = item.toJSON();
|
||||
if (itemData.latitude !== null && itemData.latitude !== undefined) {
|
||||
itemData.latitude = Math.round(parseFloat(itemData.latitude) * 100) / 100;
|
||||
}
|
||||
if (itemData.longitude !== null && itemData.longitude !== undefined) {
|
||||
itemData.longitude = Math.round(parseFloat(itemData.longitude) * 100) / 100;
|
||||
}
|
||||
return itemData;
|
||||
});
|
||||
|
||||
res.json({
|
||||
items: rows,
|
||||
items: itemsWithRoundedCoords,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
currentPage: parseInt(page),
|
||||
totalItems: count,
|
||||
@@ -90,8 +102,9 @@ router.get('/:id/reviews', async (req, res) => {
|
||||
where: {
|
||||
itemId: req.params.id,
|
||||
status: 'completed',
|
||||
rating: { [Op.not]: null },
|
||||
review: { [Op.not]: null }
|
||||
itemRating: { [Op.not]: null },
|
||||
itemReview: { [Op.not]: null },
|
||||
itemReviewVisible: true
|
||||
},
|
||||
include: [
|
||||
{
|
||||
@@ -104,7 +117,7 @@ router.get('/:id/reviews', async (req, res) => {
|
||||
});
|
||||
|
||||
const averageRating = reviews.length > 0
|
||||
? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
|
||||
? reviews.reduce((sum, review) => sum + review.itemRating, 0) / reviews.length
|
||||
: 0;
|
||||
|
||||
res.json({
|
||||
@@ -133,7 +146,16 @@ router.get("/:id", async (req, res) => {
|
||||
return res.status(404).json({ error: "Item not found" });
|
||||
}
|
||||
|
||||
res.json(item);
|
||||
// Round coordinates to 2 decimal places for map display while keeping precise values in database
|
||||
const itemResponse = item.toJSON();
|
||||
if (itemResponse.latitude !== null && itemResponse.latitude !== undefined) {
|
||||
itemResponse.latitude = Math.round(parseFloat(itemResponse.latitude) * 100) / 100;
|
||||
}
|
||||
if (itemResponse.longitude !== null && itemResponse.longitude !== undefined) {
|
||||
itemResponse.longitude = Math.round(parseFloat(itemResponse.longitude) * 100) / 100;
|
||||
}
|
||||
|
||||
res.json(itemResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
||||
198
backend/routes/maps.js
Normal file
198
backend/routes/maps.js
Normal file
@@ -0,0 +1,198 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
const rateLimiter = require("../middleware/rateLimiter");
|
||||
const googleMapsService = require("../services/googleMapsService");
|
||||
|
||||
// Input validation middleware
|
||||
const validateInput = (req, res, next) => {
|
||||
// Basic input sanitization
|
||||
if (req.body.input) {
|
||||
req.body.input = req.body.input.toString().trim();
|
||||
// Prevent extremely long inputs
|
||||
if (req.body.input.length > 500) {
|
||||
return res.status(400).json({ error: "Input too long" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.placeId) {
|
||||
req.body.placeId = req.body.placeId.toString().trim();
|
||||
// Basic place ID validation
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(req.body.placeId)) {
|
||||
return res.status(400).json({ error: "Invalid place ID format" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.address) {
|
||||
req.body.address = req.body.address.toString().trim();
|
||||
if (req.body.address.length > 500) {
|
||||
return res.status(400).json({ error: "Address too long" });
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Error handling middleware
|
||||
const handleServiceError = (error, res) => {
|
||||
console.error("Maps service error:", error.message);
|
||||
|
||||
if (error.message.includes("API key not configured")) {
|
||||
return res.status(503).json({
|
||||
error: "Maps service temporarily unavailable",
|
||||
details: "Configuration issue",
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes("quota exceeded")) {
|
||||
return res.status(429).json({
|
||||
error: "Service temporarily unavailable due to high demand",
|
||||
details: "Please try again later",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: "Failed to process request",
|
||||
details: error.message,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/maps/places/autocomplete
|
||||
* Proxy for Google Places Autocomplete API
|
||||
*/
|
||||
router.post(
|
||||
"/places/autocomplete",
|
||||
authenticateToken,
|
||||
rateLimiter.burstProtection,
|
||||
rateLimiter.placesAutocomplete,
|
||||
validateInput,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { input, types, componentRestrictions, sessionToken } = req.body;
|
||||
|
||||
if (!input || input.length < 2) {
|
||||
return res.json({ predictions: [] });
|
||||
}
|
||||
|
||||
const options = {
|
||||
types: types || ["address"],
|
||||
componentRestrictions,
|
||||
sessionToken,
|
||||
};
|
||||
|
||||
const result = await googleMapsService.getPlacesAutocomplete(
|
||||
input,
|
||||
options
|
||||
);
|
||||
|
||||
// Log request for monitoring (without sensitive data)
|
||||
console.log(
|
||||
`Places Autocomplete: user=${
|
||||
req.user?.id || "anonymous"
|
||||
}, query_length=${input.length}, results=${
|
||||
result.predictions?.length || 0
|
||||
}`
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
handleServiceError(error, res);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/maps/places/details
|
||||
* Proxy for Google Places Details API
|
||||
*/
|
||||
router.post(
|
||||
"/places/details",
|
||||
authenticateToken,
|
||||
rateLimiter.burstProtection,
|
||||
rateLimiter.placeDetails,
|
||||
validateInput,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { placeId, sessionToken } = req.body;
|
||||
|
||||
if (!placeId) {
|
||||
return res.status(400).json({ error: "Place ID is required" });
|
||||
}
|
||||
|
||||
const options = {
|
||||
sessionToken,
|
||||
};
|
||||
|
||||
const result = await googleMapsService.getPlaceDetails(placeId, options);
|
||||
|
||||
// Log request for monitoring
|
||||
console.log(
|
||||
`Place Details: user=${
|
||||
req.user?.id || "anonymous"
|
||||
}, placeId=${placeId.substring(0, 10)}...`
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
handleServiceError(error, res);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/maps/geocode
|
||||
* Proxy for Google Geocoding API
|
||||
*/
|
||||
router.post(
|
||||
"/geocode",
|
||||
authenticateToken,
|
||||
rateLimiter.burstProtection,
|
||||
rateLimiter.geocoding,
|
||||
validateInput,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { address, componentRestrictions } = req.body;
|
||||
|
||||
if (!address) {
|
||||
return res.status(400).json({ error: "Address is required" });
|
||||
}
|
||||
|
||||
const options = {
|
||||
componentRestrictions,
|
||||
};
|
||||
|
||||
const result = await googleMapsService.geocodeAddress(address, options);
|
||||
|
||||
// Log request for monitoring
|
||||
console.log(
|
||||
`Geocoding: user=${req.user?.id || "anonymous"}, address_length=${
|
||||
address.length
|
||||
}`
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
handleServiceError(error, res);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/maps/health
|
||||
* Health check endpoint for Maps service
|
||||
*/
|
||||
router.get("/health", (req, res) => {
|
||||
const isConfigured = googleMapsService.isConfigured();
|
||||
|
||||
res.status(isConfigured ? 200 : 503).json({
|
||||
status: isConfigured ? "healthy" : "unavailable",
|
||||
service: "Google Maps API Proxy",
|
||||
timestamp: new Date().toISOString(),
|
||||
configuration: {
|
||||
apiKeyConfigured: isConfigured,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -239,7 +239,13 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
{
|
||||
model: User,
|
||||
as: "renter",
|
||||
attributes: ["id", "username", "firstName", "lastName", "stripeCustomerId"],
|
||||
attributes: [
|
||||
"id",
|
||||
"username",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"stripeCustomerId",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -253,9 +259,15 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
}
|
||||
|
||||
// If owner is approving a pending rental, charge the stored payment method
|
||||
if (status === "confirmed" && rental.status === "pending" && rental.ownerId === req.user.id) {
|
||||
if (
|
||||
status === "confirmed" &&
|
||||
rental.status === "pending" &&
|
||||
rental.ownerId === req.user.id
|
||||
) {
|
||||
if (!rental.stripePaymentMethodId) {
|
||||
return res.status(400).json({ error: "No payment method found for this rental" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No payment method found for this rental" });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -264,7 +276,9 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
|
||||
// Check if renter has a stripe customer ID
|
||||
if (!rental.renter.stripeCustomerId) {
|
||||
return res.status(400).json({ error: "Renter does not have a Stripe customer account" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Renter does not have a Stripe customer account" });
|
||||
}
|
||||
|
||||
// Create payment intent and charge the stored payment method
|
||||
@@ -310,7 +324,7 @@ router.put("/:id/status", authenticateToken, async (req, res) => {
|
||||
// Keep rental as pending, but inform of payment failure
|
||||
return res.status(400).json({
|
||||
error: "Payment failed during approval",
|
||||
details: paymentError.message
|
||||
details: paymentError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -430,7 +444,6 @@ router.post("/:id/review-item", authenticateToken, async (req, res) => {
|
||||
// Mark rental as completed (owner only)
|
||||
router.post("/:id/mark-completed", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
console.log("Mark completed endpoint hit for rental ID:", req.params.id);
|
||||
const rental = await Rental.findByPk(req.params.id);
|
||||
|
||||
if (!rental) {
|
||||
|
||||
@@ -20,6 +20,7 @@ const messageRoutes = require("./routes/messages");
|
||||
const betaRoutes = require("./routes/beta");
|
||||
const itemRequestRoutes = require("./routes/itemRequests");
|
||||
const stripeRoutes = require("./routes/stripe");
|
||||
const mapsRoutes = require("./routes/maps");
|
||||
|
||||
const PayoutProcessor = require("./jobs/payoutProcessor");
|
||||
|
||||
@@ -43,6 +44,7 @@ app.use("/api/rentals", rentalRoutes);
|
||||
app.use("/api/messages", messageRoutes);
|
||||
app.use("/api/item-requests", itemRequestRoutes);
|
||||
app.use("/api/stripe", stripeRoutes);
|
||||
app.use("/api/maps", mapsRoutes);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ message: "CommunityRentals.App API is running!" });
|
||||
|
||||
241
backend/services/googleMapsService.js
Normal file
241
backend/services/googleMapsService.js
Normal file
@@ -0,0 +1,241 @@
|
||||
const { Client } = require('@googlemaps/google-maps-services-js');
|
||||
|
||||
class GoogleMapsService {
|
||||
constructor() {
|
||||
this.client = new Client({});
|
||||
this.apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||||
|
||||
if (!this.apiKey) {
|
||||
console.error('❌ Google Maps API key not configured in environment variables');
|
||||
} else {
|
||||
console.log('✅ Google Maps service initialized');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete predictions for places
|
||||
*/
|
||||
async getPlacesAutocomplete(input, options = {}) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Google Maps API key not configured');
|
||||
}
|
||||
|
||||
if (!input || input.trim().length < 2) {
|
||||
return { predictions: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
key: this.apiKey,
|
||||
input: input.trim(),
|
||||
types: options.types || 'address',
|
||||
language: options.language || 'en',
|
||||
...options
|
||||
};
|
||||
|
||||
// Add session token if provided
|
||||
if (options.sessionToken) {
|
||||
params.sessiontoken = options.sessionToken;
|
||||
}
|
||||
|
||||
// Add component restrictions (e.g., country)
|
||||
if (options.componentRestrictions) {
|
||||
params.components = Object.entries(options.componentRestrictions)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
const response = await this.client.placeAutocomplete({
|
||||
params,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
if (response.data.status === 'OK') {
|
||||
return {
|
||||
predictions: response.data.predictions.map(prediction => ({
|
||||
placeId: prediction.place_id,
|
||||
description: prediction.description,
|
||||
types: prediction.types,
|
||||
mainText: prediction.structured_formatting.main_text,
|
||||
secondaryText: prediction.structured_formatting.secondary_text || ''
|
||||
}))
|
||||
};
|
||||
} else {
|
||||
console.error('Places Autocomplete API error:', response.data.status, response.data.error_message);
|
||||
return {
|
||||
predictions: [],
|
||||
error: this.getErrorMessage(response.data.status),
|
||||
status: response.data.status
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Places Autocomplete service error:', error.message);
|
||||
throw new Error('Failed to fetch place predictions');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a place
|
||||
*/
|
||||
async getPlaceDetails(placeId, options = {}) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Google Maps API key not configured');
|
||||
}
|
||||
|
||||
if (!placeId) {
|
||||
throw new Error('Place ID is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
key: this.apiKey,
|
||||
place_id: placeId,
|
||||
fields: [
|
||||
'address_components',
|
||||
'formatted_address',
|
||||
'geometry',
|
||||
'place_id'
|
||||
],
|
||||
language: options.language || 'en'
|
||||
};
|
||||
|
||||
// Add session token if provided
|
||||
if (options.sessionToken) {
|
||||
params.sessiontoken = options.sessionToken;
|
||||
}
|
||||
|
||||
const response = await this.client.placeDetails({
|
||||
params,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
if (response.data.status === 'OK' && response.data.result) {
|
||||
const place = response.data.result;
|
||||
const addressComponents = {};
|
||||
|
||||
// Parse address components
|
||||
if (place.address_components) {
|
||||
place.address_components.forEach(component => {
|
||||
const types = component.types;
|
||||
|
||||
if (types.includes('street_number')) {
|
||||
addressComponents.streetNumber = component.long_name;
|
||||
} else if (types.includes('route')) {
|
||||
addressComponents.route = component.long_name;
|
||||
} else if (types.includes('locality')) {
|
||||
addressComponents.locality = component.long_name;
|
||||
} else if (types.includes('administrative_area_level_1')) {
|
||||
addressComponents.administrativeAreaLevel1 = component.short_name;
|
||||
addressComponents.administrativeAreaLevel1Long = component.long_name;
|
||||
} else if (types.includes('postal_code')) {
|
||||
addressComponents.postalCode = component.long_name;
|
||||
} else if (types.includes('country')) {
|
||||
addressComponents.country = component.short_name;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
placeId: place.place_id,
|
||||
formattedAddress: place.formatted_address,
|
||||
addressComponents,
|
||||
geometry: {
|
||||
latitude: place.geometry?.location?.lat || 0,
|
||||
longitude: place.geometry?.location?.lng || 0
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.error('Place Details API error:', response.data.status, response.data.error_message);
|
||||
throw new Error(this.getErrorMessage(response.data.status));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Place Details service error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocode an address to get coordinates
|
||||
*/
|
||||
async geocodeAddress(address, options = {}) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Google Maps API key not configured');
|
||||
}
|
||||
|
||||
if (!address || !address.trim()) {
|
||||
throw new Error('Address is required for geocoding');
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
key: this.apiKey,
|
||||
address: address.trim(),
|
||||
language: options.language || 'en'
|
||||
};
|
||||
|
||||
// Add component restrictions (e.g., country)
|
||||
if (options.componentRestrictions) {
|
||||
params.components = Object.entries(options.componentRestrictions)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
// Add bounds if provided
|
||||
if (options.bounds) {
|
||||
params.bounds = options.bounds;
|
||||
}
|
||||
|
||||
const response = await this.client.geocode({
|
||||
params,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
if (response.data.status === 'OK' && response.data.results.length > 0) {
|
||||
const result = response.data.results[0];
|
||||
|
||||
return {
|
||||
latitude: result.geometry.location.lat,
|
||||
longitude: result.geometry.location.lng,
|
||||
formattedAddress: result.formatted_address,
|
||||
placeId: result.place_id
|
||||
};
|
||||
} else {
|
||||
console.error('Geocoding API error:', response.data.status, response.data.error_message);
|
||||
return {
|
||||
error: this.getErrorMessage(response.data.status),
|
||||
status: response.data.status
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Geocoding service error:', error.message);
|
||||
throw new Error('Failed to geocode address');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable error message for Google Maps API status codes
|
||||
*/
|
||||
getErrorMessage(status) {
|
||||
const errorMessages = {
|
||||
'ZERO_RESULTS': 'No results found for this query',
|
||||
'OVER_QUERY_LIMIT': 'API quota exceeded. Please try again later',
|
||||
'REQUEST_DENIED': 'API request denied. Check API key configuration',
|
||||
'INVALID_REQUEST': 'Invalid request parameters',
|
||||
'UNKNOWN_ERROR': 'Server error. Please try again',
|
||||
'NOT_FOUND': 'The specified place was not found'
|
||||
};
|
||||
|
||||
return errorMessages[status] || `Google Maps API error: ${status}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the service is properly configured
|
||||
*/
|
||||
isConfigured() {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
module.exports = new GoogleMapsService();
|
||||
2140
frontend/package-lock.json
generated
2140
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@googlemaps/js-api-loader": "^1.16.10",
|
||||
"@stripe/react-stripe-js": "^3.3.1",
|
||||
"@stripe/stripe-js": "^5.2.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -19,8 +22,6 @@
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"stripe": "^18.4.0",
|
||||
"@stripe/react-stripe-js": "^3.3.1",
|
||||
"@stripe/stripe-js": "^5.2.0",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
@@ -55,6 +56,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"dotenv-cli": "^9.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,53 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { placesService, AutocompletePrediction, PlaceDetails } from '../services/placesService';
|
||||
|
||||
interface AddressSuggestion {
|
||||
place_id: string;
|
||||
display_name: string;
|
||||
lat: string;
|
||||
lon: string;
|
||||
}
|
||||
|
||||
interface AddressAutocompleteProps {
|
||||
export interface AddressAutocompleteProps {
|
||||
value: string;
|
||||
onChange: (value: string, lat?: number, lon?: number) => void;
|
||||
onChange: (value: string) => void;
|
||||
onPlaceSelect?: (place: PlaceDetails) => void;
|
||||
onError?: (error: string) => void;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
countryRestriction?: string;
|
||||
types?: string[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Address",
|
||||
required = false,
|
||||
onPlaceSelect,
|
||||
onError,
|
||||
placeholder = "Enter address",
|
||||
className = "form-control",
|
||||
id,
|
||||
name
|
||||
name,
|
||||
required = false,
|
||||
countryRestriction,
|
||||
types = ['address'],
|
||||
disabled = false
|
||||
}) => {
|
||||
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [predictions, setPredictions] = useState<AutocompletePrediction[]>([]);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [isSelectingPlace, setIsSelectingPlace] = useState(false);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const debounceTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
// Handle clicking outside to close suggestions
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
setShowDropdown(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,100 +66,249 @@ const AddressAutocomplete: React.FC<AddressAutocompleteProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchAddressSuggestions = async (query: string) => {
|
||||
if (query.length < 3) {
|
||||
setSuggestions([]);
|
||||
// Fetch autocomplete predictions
|
||||
const fetchPredictions = useCallback(async (query: string) => {
|
||||
if (query.trim().length < 2 || isSelectingPlace) {
|
||||
setPredictions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Using Nominatim API (OpenStreetMap) for free geocoding
|
||||
// In production, you might want to use Google Places API or another service
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`q=${encodeURIComponent(query)}&` +
|
||||
`format=json&` +
|
||||
`limit=5&` +
|
||||
`countrycodes=us`
|
||||
);
|
||||
setError(null);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSuggestions(data);
|
||||
try {
|
||||
const options = {
|
||||
types,
|
||||
componentRestrictions: countryRestriction ? { country: countryRestriction } : undefined
|
||||
};
|
||||
|
||||
const results = await placesService.getAutocompletePredictions(query, options);
|
||||
setPredictions(results);
|
||||
} catch (err) {
|
||||
console.error('Error fetching place predictions:', err);
|
||||
const errorMessage = 'Failed to load suggestions';
|
||||
setError(errorMessage);
|
||||
setPredictions([]);
|
||||
|
||||
// Notify parent component of error
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching address suggestions:', error);
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [types, countryRestriction, isSelectingPlace, onError]);
|
||||
|
||||
// Handle input change with debouncing
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
setShowSuggestions(true);
|
||||
setShowDropdown(true);
|
||||
setSelectedIndex(-1);
|
||||
setIsSelectingPlace(false);
|
||||
|
||||
// Debounce the API call
|
||||
// Clear previous timer
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// Debounce API calls
|
||||
debounceTimer.current = window.setTimeout(() => {
|
||||
fetchAddressSuggestions(newValue);
|
||||
fetchPredictions(newValue);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleSuggestionClick = (suggestion: AddressSuggestion) => {
|
||||
onChange(
|
||||
suggestion.display_name,
|
||||
parseFloat(suggestion.lat),
|
||||
parseFloat(suggestion.lon)
|
||||
);
|
||||
setShowSuggestions(false);
|
||||
setSuggestions([]);
|
||||
// Handle place selection
|
||||
const handlePlaceSelect = async (prediction: AutocompletePrediction) => {
|
||||
setIsSelectingPlace(true);
|
||||
setLoading(true);
|
||||
setShowDropdown(false);
|
||||
setSelectedIndex(-1);
|
||||
|
||||
try {
|
||||
const placeDetails = await placesService.getPlaceDetails(prediction.placeId);
|
||||
onChange(placeDetails.formattedAddress);
|
||||
|
||||
if (onPlaceSelect) {
|
||||
onPlaceSelect(placeDetails);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching place details:', err);
|
||||
const errorMessage = 'Failed to load place details';
|
||||
setError(errorMessage);
|
||||
|
||||
// Keep the description as fallback
|
||||
onChange(prediction.description);
|
||||
|
||||
// Notify parent component of error
|
||||
if (onError) {
|
||||
onError(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsSelectingPlace(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showDropdown || predictions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < predictions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < predictions.length) {
|
||||
handlePlaceSelect(predictions[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
setShowDropdown(false);
|
||||
setSelectedIndex(-1);
|
||||
inputRef.current?.blur();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input focus
|
||||
const handleFocus = () => {
|
||||
if (predictions.length > 0) {
|
||||
setShowDropdown(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="position-relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={className}
|
||||
className={`${className} ${loading ? 'pe-5' : ''}`}
|
||||
id={id}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
aria-expanded={showDropdown}
|
||||
aria-haspopup="listbox"
|
||||
aria-owns={showDropdown ? `${id}-dropdown` : undefined}
|
||||
aria-activedescendant={
|
||||
selectedIndex >= 0 ? `${id}-option-${selectedIndex}` : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||||
{/* Loading spinner */}
|
||||
{loading && (
|
||||
<div
|
||||
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
|
||||
style={{ top: '100%', zIndex: 1000, maxHeight: '300px', overflowY: 'auto' }}
|
||||
className="position-absolute top-50 end-0 translate-middle-y me-3"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-2 text-center text-muted">
|
||||
<small>Searching addresses...</small>
|
||||
<div
|
||||
className="spinner-border spinner-border-sm text-muted"
|
||||
role="status"
|
||||
style={{ width: '1rem', height: '1rem' }}
|
||||
>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown with predictions */}
|
||||
{showDropdown && (predictions.length > 0 || error || loading) && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
id={`${id}-dropdown`}
|
||||
className="position-absolute w-100 bg-white border rounded-bottom shadow-sm"
|
||||
style={{
|
||||
top: '100%',
|
||||
zIndex: 1050,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderTop: 'none',
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{error && (
|
||||
<div className="p-3 text-center text-danger">
|
||||
<small>
|
||||
<i className="bi bi-exclamation-triangle me-1"></i>
|
||||
{error}
|
||||
</small>
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.place_id}
|
||||
className="p-2 border-bottom cursor-pointer"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleSuggestionClick(suggestion)}
|
||||
onMouseEnter={(e) => e.currentTarget.classList.add('bg-light')}
|
||||
onMouseLeave={(e) => e.currentTarget.classList.remove('bg-light')}
|
||||
>
|
||||
<small className="d-block text-truncate">
|
||||
{suggestion.display_name}
|
||||
</small>
|
||||
)}
|
||||
|
||||
{loading && predictions.length === 0 && !error && (
|
||||
<div className="p-3 text-center text-muted">
|
||||
<small>
|
||||
<i className="bi bi-search me-1"></i>
|
||||
Searching addresses...
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{predictions.map((prediction, index) => (
|
||||
<div
|
||||
key={prediction.placeId}
|
||||
id={`${id}-option-${index}`}
|
||||
className={`p-3 border-bottom cursor-pointer ${
|
||||
index === selectedIndex ? 'bg-light' : ''
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlePlaceSelect(prediction)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onMouseLeave={() => setSelectedIndex(-1)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<i className="bi bi-geo-alt text-muted me-2 mt-1" style={{ fontSize: '0.875rem' }}></i>
|
||||
<div className="flex-grow-1 min-width-0">
|
||||
<div className="text-truncate fw-medium">
|
||||
{prediction.mainText}
|
||||
</div>
|
||||
{prediction.secondaryText && (
|
||||
<div className="text-muted small text-truncate">
|
||||
{prediction.secondaryText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
</div>
|
||||
))}
|
||||
|
||||
{predictions.length > 0 && (
|
||||
<div className="p-2 text-center border-top bg-light">
|
||||
<small className="text-muted d-flex align-items-center justify-content-center">
|
||||
<img
|
||||
src="https://developers.google.com/maps/documentation/places/web-service/images/powered_by_google_on_white.png"
|
||||
alt="Powered by Google"
|
||||
style={{ height: '12px' }}
|
||||
/>
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
151
frontend/src/components/GoogleMapWithRadius.tsx
Normal file
151
frontend/src/components/GoogleMapWithRadius.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useMemo, useEffect, useRef } from "react";
|
||||
import { Loader } from "@googlemaps/js-api-loader";
|
||||
|
||||
interface GoogleMapWithRadiusProps {
|
||||
latitude?: number | string;
|
||||
longitude?: number | string;
|
||||
mapOptions?: {
|
||||
zoom?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Utility function to safely convert coordinates to numbers
|
||||
const safeParseNumber = (value: number | string | undefined): number | null => {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const num = typeof value === "string" ? parseFloat(value) : value;
|
||||
return !isNaN(num) && isFinite(num) ? num : null;
|
||||
};
|
||||
|
||||
// 2 miles in meters for radius circle
|
||||
const RADIUS_METERS = 2 * 1609.34;
|
||||
|
||||
const GoogleMapWithRadius: React.FC<GoogleMapWithRadiusProps> = ({
|
||||
latitude: rawLatitude,
|
||||
longitude: rawLongitude,
|
||||
mapOptions = {},
|
||||
}) => {
|
||||
// Convert coordinates to numbers safely
|
||||
const latitude = safeParseNumber(rawLatitude);
|
||||
const longitude = safeParseNumber(rawLongitude);
|
||||
|
||||
// Destructure mapOptions to create stable references
|
||||
const { zoom = 12 } = mapOptions;
|
||||
|
||||
// Get API key from environment
|
||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
|
||||
|
||||
// Refs for map container and instances
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||||
const circleRef = useRef<google.maps.Circle | null>(null);
|
||||
|
||||
// Memoize map center
|
||||
const mapCenter = useMemo(() => {
|
||||
if (latitude === null || longitude === null) return null;
|
||||
return { lat: latitude, lng: longitude };
|
||||
}, [latitude, longitude]);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!apiKey || !mapRef.current || !mapCenter) return;
|
||||
|
||||
const initializeMap = async () => {
|
||||
const loader = new Loader({
|
||||
apiKey,
|
||||
version: "weekly",
|
||||
});
|
||||
|
||||
try {
|
||||
await loader.importLibrary("maps");
|
||||
|
||||
if (!mapRef.current) return;
|
||||
|
||||
// Create map
|
||||
const map = new google.maps.Map(mapRef.current, {
|
||||
center: mapCenter,
|
||||
zoom: zoom,
|
||||
zoomControl: true,
|
||||
mapTypeControl: false,
|
||||
scaleControl: true,
|
||||
streetViewControl: false,
|
||||
rotateControl: false,
|
||||
fullscreenControl: false,
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Create circle overlay
|
||||
const circle = new google.maps.Circle({
|
||||
center: mapCenter,
|
||||
radius: RADIUS_METERS,
|
||||
fillColor: "#6c757d",
|
||||
fillOpacity: 0.2,
|
||||
strokeColor: "#6c757d",
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 2,
|
||||
map: map,
|
||||
});
|
||||
|
||||
circleRef.current = circle;
|
||||
} catch (error) {
|
||||
console.error("Failed to load Google Maps:", error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeMap();
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (circleRef.current) {
|
||||
circleRef.current.setMap(null);
|
||||
}
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, [apiKey, mapCenter, zoom]);
|
||||
|
||||
// Update map center and circle when coordinates change
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !circleRef.current || !mapCenter) return;
|
||||
|
||||
mapInstanceRef.current.setCenter(mapCenter);
|
||||
circleRef.current.setCenter(mapCenter);
|
||||
}, [mapCenter]);
|
||||
|
||||
// Handle case where no coordinates are available
|
||||
if (latitude === null || longitude === null || mapCenter === null) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h5>Location</h5>
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
height: "300px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
<p className="text-muted small">Map unavailable</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h5>Location</h5>
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{
|
||||
height: "300px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#f8f9fa",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleMapWithRadius;
|
||||
@@ -29,7 +29,7 @@ const ItemCard: React.FC<ItemCardProps> = ({
|
||||
const getLocationDisplay = () => {
|
||||
return item.city && item.state
|
||||
? `${item.city}, ${item.state}`
|
||||
: item.location;
|
||||
: '';
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Address } from '../types';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Address } from "../types";
|
||||
import {
|
||||
geocodingService,
|
||||
AddressComponents,
|
||||
} from "../services/geocodingService";
|
||||
import AddressAutocomplete from "./AddressAutocomplete";
|
||||
import { PlaceDetails } from "../services/placesService";
|
||||
|
||||
interface LocationFormData {
|
||||
address1: string;
|
||||
@@ -17,11 +23,129 @@ interface LocationFormProps {
|
||||
userAddresses: Address[];
|
||||
selectedAddressId: string;
|
||||
addressesLoading: boolean;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||
onChange: (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => void;
|
||||
onAddressSelect: (addressId: string) => void;
|
||||
formatAddressDisplay: (address: Address) => string;
|
||||
onCoordinatesChange?: (latitude: number, longitude: number) => void;
|
||||
onGeocodeRef?: (geocodeFunction: () => Promise<boolean>) => 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> = ({
|
||||
data,
|
||||
userAddresses,
|
||||
@@ -29,18 +153,155 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
addressesLoading,
|
||||
onChange,
|
||||
onAddressSelect,
|
||||
formatAddressDisplay
|
||||
formatAddressDisplay,
|
||||
onCoordinatesChange,
|
||||
onGeocodeRef,
|
||||
}) => {
|
||||
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"
|
||||
];
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [geocodeError, setGeocodeError] = useState<string | null>(null);
|
||||
const [geocodeSuccess, setGeocodeSuccess] = useState(false);
|
||||
const [placesApiError, setPlacesApiError] = useState(false);
|
||||
|
||||
// Debounced geocoding function
|
||||
const geocodeAddress = useCallback(
|
||||
async (addressData: LocationFormData) => {
|
||||
if (
|
||||
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGeocoding(true);
|
||||
setGeocodeError(null);
|
||||
setGeocodeSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await geocodingService.geocodeAddress(
|
||||
addressData as AddressComponents
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
setGeocodeError(result.details || result.error);
|
||||
} else {
|
||||
setGeocodeSuccess(true);
|
||||
if (onCoordinatesChange) {
|
||||
onCoordinatesChange(result.latitude, result.longitude);
|
||||
}
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setGeocodeSuccess(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setGeocodeError("Failed to geocode address");
|
||||
} finally {
|
||||
setGeocoding(false);
|
||||
}
|
||||
},
|
||||
[onCoordinatesChange]
|
||||
);
|
||||
|
||||
// Expose geocoding function to parent components
|
||||
const triggerGeocoding = useCallback(async () => {
|
||||
if (data.address1 && data.city && data.state && data.zipCode) {
|
||||
await geocodeAddress(data);
|
||||
return true; // Successfully triggered
|
||||
}
|
||||
return false; // Incomplete address
|
||||
}, [data, geocodeAddress]);
|
||||
|
||||
// Pass geocoding function to parent component
|
||||
useEffect(() => {
|
||||
if (onGeocodeRef) {
|
||||
onGeocodeRef(triggerGeocoding);
|
||||
}
|
||||
}, [onGeocodeRef, triggerGeocoding]);
|
||||
|
||||
// Handle place selection from autocomplete
|
||||
const handlePlaceSelect = useCallback(
|
||||
(place: PlaceDetails) => {
|
||||
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();
|
||||
|
||||
// Create synthetic events to update form data
|
||||
const createSyntheticEvent = (name: string, value: string) =>
|
||||
({
|
||||
target: {
|
||||
name,
|
||||
value,
|
||||
type: "text",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
|
||||
// Update all address fields
|
||||
onChange(
|
||||
createSyntheticEvent("address1", address1 || place.formattedAddress)
|
||||
);
|
||||
|
||||
if (addressComponents.locality) {
|
||||
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
|
||||
if (
|
||||
onCoordinatesChange &&
|
||||
place.geometry.latitude &&
|
||||
place.geometry.longitude
|
||||
) {
|
||||
onCoordinatesChange(
|
||||
place.geometry.latitude,
|
||||
place.geometry.longitude
|
||||
);
|
||||
}
|
||||
|
||||
// Clear any previous geocoding messages
|
||||
setGeocodeError(null);
|
||||
setGeocodeSuccess(true);
|
||||
setPlacesApiError(false);
|
||||
setTimeout(() => setGeocodeSuccess(false), 3000);
|
||||
} catch (error) {
|
||||
console.error("Error handling place selection:", error);
|
||||
setPlacesApiError(true);
|
||||
}
|
||||
},
|
||||
[onChange, onCoordinatesChange]
|
||||
);
|
||||
|
||||
// Handle Places API errors
|
||||
const handlePlacesApiError = useCallback(() => {
|
||||
setPlacesApiError(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="card mb-4">
|
||||
@@ -48,8 +309,8 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
<div className="mb-3">
|
||||
<small className="text-muted">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
Your address is private. This will only be used to show
|
||||
renters a general area.
|
||||
Your address is private. This will only be used to show renters a
|
||||
general area.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +332,7 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
onChange={(e) => onAddressSelect(e.target.value)}
|
||||
>
|
||||
<option value="new">Enter new address</option>
|
||||
{userAddresses.map(address => (
|
||||
{userAddresses.map((address) => (
|
||||
<option key={address.id} value={address.id}>
|
||||
{formatAddressDisplay(address)}
|
||||
</option>
|
||||
@@ -86,19 +347,54 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
<>
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="address1" className="form-label">
|
||||
Address Line 1 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={data.address1}
|
||||
onChange={onChange}
|
||||
placeholder="123 Main Street"
|
||||
required
|
||||
/>
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<label htmlFor="address1" className="form-label mb-0">
|
||||
Address Line 1 *
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!placesApiError ? (
|
||||
<AddressAutocomplete
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={data.address1}
|
||||
onChange={(value) => {
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name: "address1",
|
||||
value,
|
||||
type: "text",
|
||||
},
|
||||
} as React.ChangeEvent<HTMLInputElement>;
|
||||
onChange(syntheticEvent);
|
||||
}}
|
||||
onPlaceSelect={handlePlaceSelect}
|
||||
onError={handlePlacesApiError}
|
||||
placeholder="Start typing an address..."
|
||||
className="form-control"
|
||||
required
|
||||
countryRestriction="us"
|
||||
types={["address"]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={data.address1}
|
||||
onChange={onChange}
|
||||
placeholder="123 Main Street"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
{placesApiError && (
|
||||
<div className="text-muted small mt-1">
|
||||
<i className="bi bi-info-circle me-1"></i>
|
||||
Address autocomplete is unavailable. Using manual input.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label htmlFor="address2" className="form-label">
|
||||
@@ -144,7 +440,7 @@ const LocationForm: React.FC<LocationFormProps> = ({
|
||||
required
|
||||
>
|
||||
<option value="">Select State</option>
|
||||
{usStates.map(state => (
|
||||
{usStates.map((state) => (
|
||||
<option key={state} value={state}>
|
||||
{state}
|
||||
</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import api, { addressAPI, userAPI, itemAPI } from "../services/api";
|
||||
@@ -84,6 +84,9 @@ const CreateItem: React.FC = () => {
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||
const [addressesLoading, setAddressesLoading] = useState(true);
|
||||
|
||||
// Reference to LocationForm geocoding function
|
||||
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserAddresses();
|
||||
fetchUserAvailability();
|
||||
@@ -150,6 +153,15 @@ const CreateItem: React.FC = () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
// Try to geocode the address before submitting
|
||||
if (geocodeLocationRef.current) {
|
||||
try {
|
||||
await geocodeLocationRef.current();
|
||||
} catch (error) {
|
||||
console.warn('Geocoding failed, creating item without coordinates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, we'll store image URLs as base64 strings
|
||||
// In production, you'd upload to a service like S3
|
||||
@@ -253,6 +265,14 @@ const CreateItem: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoordinatesChange = (latitude: number, longitude: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
latitude,
|
||||
longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddressSelect = (addressId: string) => {
|
||||
if (addressId === "new") {
|
||||
// Clear form for new address entry
|
||||
@@ -379,6 +399,10 @@ const CreateItem: React.FC = () => {
|
||||
onChange={handleChange}
|
||||
onAddressSelect={handleAddressSelect}
|
||||
formatAddressDisplay={formatAddressDisplay}
|
||||
onCoordinatesChange={handleCoordinatesChange}
|
||||
onGeocodeRef={(geocodeFunction) => {
|
||||
geocodeLocationRef.current = geocodeFunction;
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeliveryOptions
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemRequestAPI } from "../services/api";
|
||||
import AddressAutocomplete from "../components/AddressAutocomplete";
|
||||
|
||||
const CreateItemRequest: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -42,18 +41,6 @@ const CreateItemRequest: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddressChange = (value: string, lat?: number, lon?: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
address1: value,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
city: prev.city,
|
||||
state: prev.state,
|
||||
zipCode: prev.zipCode,
|
||||
country: prev.country,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -186,10 +173,14 @@ const CreateItemRequest: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Address</label>
|
||||
<AddressAutocomplete
|
||||
<label htmlFor="address1" className="form-label">Address</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="address1"
|
||||
name="address1"
|
||||
value={formData.address1}
|
||||
onChange={handleAddressChange}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter your address or area"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Item, Rental, Address } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
@@ -58,6 +58,9 @@ const EditItem: React.FC = () => {
|
||||
const [userAddresses, setUserAddresses] = useState<Address[]>([]);
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string>("");
|
||||
const [addressesLoading, setAddressesLoading] = useState(true);
|
||||
|
||||
// Reference to LocationForm geocoding function
|
||||
const geocodeLocationRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const [formData, setFormData] = useState<ItemFormData>({
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -204,10 +207,27 @@ const EditItem: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoordinatesChange = (latitude: number, longitude: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
latitude,
|
||||
longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Try to geocode the address before submitting
|
||||
if (geocodeLocationRef.current) {
|
||||
try {
|
||||
await geocodeLocationRef.current();
|
||||
} catch (error) {
|
||||
console.warn('Geocoding failed, updating item without coordinates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use existing image previews (which includes both old and new images)
|
||||
const imageUrls = imagePreviews;
|
||||
@@ -412,6 +432,10 @@ const EditItem: React.FC = () => {
|
||||
onChange={handleChange}
|
||||
onAddressSelect={handleAddressSelect}
|
||||
formatAddressDisplay={formatAddressDisplay}
|
||||
onCoordinatesChange={handleCoordinatesChange}
|
||||
onGeocodeRef={(geocodeFunction) => {
|
||||
geocodeLocationRef.current = geocodeFunction;
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeliveryOptions
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import { Item, Rental } from "../types";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { itemAPI, rentalAPI } from "../services/api";
|
||||
import LocationMap from "../components/LocationMap";
|
||||
import GoogleMapWithRadius from "../components/GoogleMapWithRadius";
|
||||
import ItemReviews from "../components/ItemReviews";
|
||||
|
||||
const ItemDetail: React.FC = () => {
|
||||
@@ -357,11 +357,9 @@ const ItemDetail: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<LocationMap
|
||||
<GoogleMapWithRadius
|
||||
latitude={item.latitude}
|
||||
longitude={item.longitude}
|
||||
location={item.location}
|
||||
itemName={item.name}
|
||||
/>
|
||||
|
||||
<ItemReviews itemId={item.id} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { userAPI, itemAPI, rentalAPI, addressAPI } from "../services/api";
|
||||
import { User, Item, Rental, Address } from "../types";
|
||||
@@ -6,8 +6,11 @@ import { getImageUrl } from "../utils/imageUrl";
|
||||
import AvailabilitySettings from "../components/AvailabilitySettings";
|
||||
import ReviewItemModal from "../components/ReviewModal";
|
||||
import ReviewRenterModal from "../components/ReviewRenterModal";
|
||||
import StarRating from "../components/StarRating";
|
||||
import ReviewDetailsModal from "../components/ReviewDetailsModal";
|
||||
import {
|
||||
geocodingService,
|
||||
AddressComponents,
|
||||
} from "../services/geocodingService";
|
||||
|
||||
const Profile: React.FC = () => {
|
||||
const { user, updateUser, logout } = useAuth();
|
||||
@@ -62,7 +65,14 @@ const Profile: React.FC = () => {
|
||||
state: "",
|
||||
zipCode: "",
|
||||
country: "US",
|
||||
latitude: undefined as number | undefined,
|
||||
longitude: undefined as number | undefined,
|
||||
});
|
||||
const [addressGeocoding, setAddressGeocoding] = useState(false);
|
||||
const [addressGeocodeError, setAddressGeocodeError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [addressGeocodeSuccess, setAddressGeocodeSuccess] = useState(false);
|
||||
|
||||
// Rental history state
|
||||
const [pastRenterRentals, setPastRenterRentals] = useState<Rental[]>([]);
|
||||
@@ -404,6 +414,8 @@ const Profile: React.FC = () => {
|
||||
state: "",
|
||||
zipCode: "",
|
||||
country: "US",
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
});
|
||||
setEditingAddressId(null);
|
||||
setShowAddressForm(true);
|
||||
@@ -417,6 +429,8 @@ const Profile: React.FC = () => {
|
||||
state: address.state,
|
||||
zipCode: address.zipCode,
|
||||
country: address.country,
|
||||
latitude: address.latitude,
|
||||
longitude: address.longitude,
|
||||
});
|
||||
setEditingAddressId(address.id);
|
||||
setShowAddressForm(true);
|
||||
@@ -429,8 +443,59 @@ const Profile: React.FC = () => {
|
||||
setAddressFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Geocoding function for address form
|
||||
const geocodeAddressForm = useCallback(
|
||||
async (addressData: typeof addressFormData) => {
|
||||
if (
|
||||
!geocodingService.isAddressComplete(addressData as AddressComponents)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAddressGeocoding(true);
|
||||
setAddressGeocodeError(null);
|
||||
setAddressGeocodeSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await geocodingService.geocodeAddress(
|
||||
addressData as AddressComponents
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
setAddressGeocodeError(result.details || result.error);
|
||||
} else {
|
||||
setAddressGeocodeSuccess(true);
|
||||
setAddressFormData((prev) => ({
|
||||
...prev,
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
}));
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => setAddressGeocodeSuccess(false), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
setAddressGeocodeError("Failed to geocode address");
|
||||
} finally {
|
||||
setAddressGeocoding(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSaveAddress = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Try to geocode the address before saving
|
||||
try {
|
||||
await geocodeAddressForm(addressFormData);
|
||||
} catch (error) {
|
||||
// Geocoding failed, but we'll continue with saving
|
||||
console.warn(
|
||||
"Geocoding failed, saving address without coordinates:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingAddressId) {
|
||||
// Update existing address
|
||||
@@ -469,7 +534,12 @@ const Profile: React.FC = () => {
|
||||
state: "",
|
||||
zipCode: "",
|
||||
country: "US",
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
});
|
||||
setAddressGeocoding(false);
|
||||
setAddressGeocodeError(null);
|
||||
setAddressGeocodeSuccess(false);
|
||||
};
|
||||
|
||||
const usStates = [
|
||||
|
||||
@@ -129,7 +129,7 @@ const PublicProfile: React.FC = () => {
|
||||
)}
|
||||
<div className="card-body">
|
||||
<h6 className="card-title">{item.name}</h6>
|
||||
<p className="card-text text-muted small">{item.location}</p>
|
||||
<p className="card-text text-muted small">{item.city && item.state ? `${item.city}, ${item.state}` : ''}</p>
|
||||
<div>
|
||||
{item.pricePerDay && (
|
||||
<span className="badge bg-primary">${item.pricePerDay}/day</span>
|
||||
|
||||
@@ -280,7 +280,7 @@ const RentItem: React.FC = () => {
|
||||
<p className="text-muted small">
|
||||
{item.city && item.state
|
||||
? `${item.city}, ${item.state}`
|
||||
: item.location}
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -119,9 +119,24 @@ export const stripeAPI = {
|
||||
createAccountLink: (data: { refreshUrl: string; returnUrl: string }) =>
|
||||
api.post("/stripe/account-links", data),
|
||||
getAccountStatus: () => api.get("/stripe/account-status"),
|
||||
createSetupCheckoutSession: (data: {
|
||||
rentalData?: any;
|
||||
}) => api.post("/stripe/create-setup-checkout-session", data),
|
||||
createSetupCheckoutSession: (data: { rentalData?: any }) =>
|
||||
api.post("/stripe/create-setup-checkout-session", data),
|
||||
};
|
||||
|
||||
export const mapsAPI = {
|
||||
placesAutocomplete: (data: {
|
||||
input: string;
|
||||
types?: string[];
|
||||
componentRestrictions?: { country: string };
|
||||
sessionToken?: string;
|
||||
}) => api.post("/maps/places/autocomplete", data),
|
||||
placeDetails: (data: { placeId: string; sessionToken?: string }) =>
|
||||
api.post("/maps/places/details", data),
|
||||
geocode: (data: {
|
||||
address: string;
|
||||
componentRestrictions?: { country: string };
|
||||
}) => api.post("/maps/geocode", data),
|
||||
getHealth: () => api.get("/maps/health"),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
118
frontend/src/services/geocodingService.ts
Normal file
118
frontend/src/services/geocodingService.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { mapsAPI } from "./api";
|
||||
|
||||
interface AddressComponents {
|
||||
address1: string;
|
||||
address2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface GeocodeResult {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
formattedAddress?: string;
|
||||
}
|
||||
|
||||
interface GeocodeError {
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
type GeocodeResponse = GeocodeResult | GeocodeError;
|
||||
|
||||
class GeocodingService {
|
||||
private cache: Map<string, GeocodeResult> = new Map();
|
||||
|
||||
/**
|
||||
* Convert address components to lat/lng coordinates using backend geocoding proxy
|
||||
*/
|
||||
async geocodeAddress(address: AddressComponents): Promise<GeocodeResponse> {
|
||||
// Create address string for geocoding
|
||||
const addressParts = [
|
||||
address.address1,
|
||||
address.address2,
|
||||
address.city,
|
||||
address.state,
|
||||
address.zipCode,
|
||||
address.country,
|
||||
].filter((part) => part && part.trim());
|
||||
|
||||
const addressString = addressParts.join(", ");
|
||||
const cacheKey = addressString.toLowerCase();
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.geocode({
|
||||
address: addressString,
|
||||
componentRestrictions: {
|
||||
country: address.country?.toLowerCase() || "us",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.latitude && response.data.longitude) {
|
||||
const geocodeResult: GeocodeResult = {
|
||||
latitude: response.data.latitude,
|
||||
longitude: response.data.longitude,
|
||||
formattedAddress: response.data.formattedAddress || addressString,
|
||||
};
|
||||
|
||||
// Cache successful result
|
||||
this.cache.set(cacheKey, geocodeResult);
|
||||
return geocodeResult;
|
||||
} else if (response.data.error) {
|
||||
return {
|
||||
error: "Geocoding failed",
|
||||
details: response.data.error,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: "Geocoding failed",
|
||||
details: "No coordinates returned",
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Geocoding API error:", error.message);
|
||||
|
||||
if (error.response?.status === 429) {
|
||||
return {
|
||||
error: "Too many geocoding requests",
|
||||
details: "Please slow down and try again",
|
||||
};
|
||||
} else if (error.response?.status === 401) {
|
||||
return {
|
||||
error: "Authentication required",
|
||||
details: "Please log in to use geocoding",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: "Network error during geocoding",
|
||||
details: error.message || "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if address has sufficient components for geocoding
|
||||
*/
|
||||
isAddressComplete(address: AddressComponents): boolean {
|
||||
return !!(
|
||||
address.address1?.trim() &&
|
||||
address.city?.trim() &&
|
||||
address.state?.trim() &&
|
||||
address.zipCode?.trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const geocodingService = new GeocodingService();
|
||||
|
||||
// Export types for use in other components
|
||||
export type { AddressComponents, GeocodeResult, GeocodeError, GeocodeResponse };
|
||||
135
frontend/src/services/placesService.ts
Normal file
135
frontend/src/services/placesService.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mapsAPI } from "./api";
|
||||
|
||||
// Define types for place details
|
||||
export interface PlaceDetails {
|
||||
formattedAddress: string;
|
||||
addressComponents: {
|
||||
streetNumber?: string;
|
||||
route?: string;
|
||||
locality?: string;
|
||||
administrativeAreaLevel1?: string;
|
||||
administrativeAreaLevel1Long?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
geometry: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
placeId: string;
|
||||
}
|
||||
|
||||
export interface AutocompletePrediction {
|
||||
placeId: string;
|
||||
description: string;
|
||||
types: string[];
|
||||
mainText: string;
|
||||
secondaryText: string;
|
||||
}
|
||||
|
||||
class PlacesService {
|
||||
private sessionToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Generate a new session token for cost optimization
|
||||
*/
|
||||
private generateSessionToken(): string {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autocomplete predictions for a query
|
||||
*/
|
||||
async getAutocompletePredictions(
|
||||
input: string,
|
||||
options?: {
|
||||
types?: string[];
|
||||
componentRestrictions?: { country: string };
|
||||
bounds?: any;
|
||||
}
|
||||
): Promise<AutocompletePrediction[]> {
|
||||
if (input.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Generate new session token if not exists
|
||||
if (!this.sessionToken) {
|
||||
this.sessionToken = this.generateSessionToken();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.placesAutocomplete({
|
||||
input: input.trim(),
|
||||
types: options?.types || ["address"],
|
||||
componentRestrictions: options?.componentRestrictions,
|
||||
sessionToken: this.sessionToken || undefined,
|
||||
});
|
||||
|
||||
if (response.data.predictions) {
|
||||
return response.data.predictions;
|
||||
} else if (response.data.error) {
|
||||
console.error("Places Autocomplete API error:", response.data.error);
|
||||
throw new Error(response.data.error);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching place predictions:", error.message);
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Too many requests. Please slow down.");
|
||||
} else if (error.response?.status === 401) {
|
||||
throw new Error("Authentication required. Please log in.");
|
||||
} else {
|
||||
throw new Error("Failed to fetch place suggestions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed place information by place ID
|
||||
*/
|
||||
async getPlaceDetails(placeId: string): Promise<PlaceDetails> {
|
||||
if (!placeId) {
|
||||
throw new Error("Place ID is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mapsAPI.placeDetails({
|
||||
placeId,
|
||||
sessionToken: this.sessionToken || undefined,
|
||||
});
|
||||
|
||||
// Clear session token after successful place details request
|
||||
this.sessionToken = null;
|
||||
|
||||
if (response.data.placeId) {
|
||||
return {
|
||||
formattedAddress: response.data.formattedAddress,
|
||||
addressComponents: response.data.addressComponents,
|
||||
geometry: response.data.geometry,
|
||||
placeId: response.data.placeId,
|
||||
};
|
||||
} else if (response.data.error) {
|
||||
console.error("Place Details API error:", response.data.error);
|
||||
throw new Error(response.data.error);
|
||||
}
|
||||
|
||||
throw new Error("Invalid response from place details API");
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching place details:", error.message);
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error("Too many requests. Please slow down.");
|
||||
} else if (error.response?.status === 401) {
|
||||
throw new Error("Authentication required. Please log in.");
|
||||
} else {
|
||||
throw new Error("Failed to fetch place details");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export singleton instance
|
||||
export const placesService = new PlacesService();
|
||||
@@ -62,7 +62,6 @@ export interface Item {
|
||||
pricePerWeek?: number;
|
||||
pricePerMonth?: number;
|
||||
replacementCost: number;
|
||||
location: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
city?: string;
|
||||
|
||||
Reference in New Issue
Block a user