Compare commits

...

2 Commits

Author SHA1 Message Date
jackiettran
1d7db138df Google maps integration 2025-09-09 22:49:55 -04:00
jackiettran
69bf64fe70 review bug 2025-09-04 22:02:49 -04:00
25 changed files with 3715 additions and 580 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

@@ -29,7 +29,7 @@ const ItemCard: React.FC<ItemCardProps> = ({
const getLocationDisplay = () => {
return item.city && item.state
? `${item.city}, ${item.state}`
: item.location;
: '';
};
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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();

View File

@@ -62,7 +62,6 @@ export interface Item {
pricePerWeek?: number;
pricePerMonth?: number;
replacementCost: number;
location: string;
address1?: string;
address2?: string;
city?: string;