mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Merge pull request #661 from mauriceboe/feat/search-autocomplete
fix(search-autocomplete): address PR #542 review issues
This commit is contained in:
Generated
+24
-10
@@ -171,6 +171,7 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1806,6 +1807,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@@ -1854,6 +1856,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@@ -3822,8 +3825,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -3964,6 +3966,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -3975,6 +3978,7 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@@ -4217,6 +4221,7 @@
|
|||||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -4244,7 +4249,6 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -4645,6 +4649,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -5392,8 +5397,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -7129,6 +7133,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -7256,7 +7261,8 @@
|
|||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/leaflet.markercluster": {
|
"node_modules/leaflet.markercluster": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.3",
|
||||||
@@ -7391,7 +7397,6 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -8432,6 +8437,7 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/confirm": "^5.0.0",
|
"@inquirer/confirm": "^5.0.0",
|
||||||
"@mswjs/interceptors": "^0.41.2",
|
"@mswjs/interceptors": "^0.41.2",
|
||||||
@@ -8817,6 +8823,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -8978,7 +8985,6 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -9079,6 +9085,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -9091,6 +9098,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -9130,14 +9138,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||||
"license": "Hippocratic-2.1",
|
"license": "Hippocratic-2.1",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-leaflet/core": "^2.1.0"
|
"@react-leaflet/core": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -9532,6 +9540,7 @@
|
|||||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -10649,6 +10658,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -10898,6 +10908,7 @@
|
|||||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -11216,6 +11227,7 @@
|
|||||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -11344,6 +11356,7 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@@ -11841,6 +11854,7 @@
|
|||||||
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
"integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ export const journeyApi = {
|
|||||||
|
|
||||||
export const mapsApi = {
|
export const mapsApi = {
|
||||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||||
|
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||||
|
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import type { Place, Category, Assignment } from '../../types'
|
import type { Place, Category, Assignment } from '../../types'
|
||||||
@@ -25,6 +25,25 @@ interface PlaceFormData {
|
|||||||
website: string
|
website: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGoogleMapsUrl(input: string): boolean {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(input.trim())
|
||||||
|
const h = hostname.toLowerCase()
|
||||||
|
// maps.app.goo.gl, goo.gl/maps
|
||||||
|
if (h === 'maps.app.goo.gl') return true
|
||||||
|
if (h === 'goo.gl' && pathname.startsWith('/maps')) return true
|
||||||
|
// maps.google.* (e.g. maps.google.com, maps.google.co.uk)
|
||||||
|
// Must be maps.google.<tld> or maps.google.<sld>.<tld> — reject maps.google.evil.com
|
||||||
|
if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true
|
||||||
|
// google.*/maps (e.g. google.com/maps, www.google.co.uk/maps)
|
||||||
|
const bare = h.startsWith('www.') ? h.slice(4) : h
|
||||||
|
if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_FORM: PlaceFormData = {
|
const DEFAULT_FORM: PlaceFormData = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -65,6 +84,10 @@ export default function PlaceFormModal({
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
|
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||||
|
const [acHighlight, setAcHighlight] = useState(-1)
|
||||||
|
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const acAbortRef = useRef<AbortController | null>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const { hasMapsKey } = useAuthStore()
|
const { hasMapsKey } = useAuthStore()
|
||||||
@@ -101,6 +124,73 @@ export default function PlaceFormModal({
|
|||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
}, [place, prefillCoords, isOpen])
|
}, [place, prefillCoords, isOpen])
|
||||||
|
|
||||||
|
// Derive location bias bounding box from the trip's existing places
|
||||||
|
const places = useTripStore((s) => s.places)
|
||||||
|
const locationBias = useMemo(() => {
|
||||||
|
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
||||||
|
if (withCoords.length === 0) return undefined
|
||||||
|
|
||||||
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||||
|
for (const p of withCoords) {
|
||||||
|
const lat = Number(p.lat), lng = Number(p.lng)
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||||
|
if (lat < minLat) minLat = lat
|
||||||
|
if (lat > maxLat) maxLat = lat
|
||||||
|
if (lng < minLng) minLng = lng
|
||||||
|
if (lng > maxLng) maxLng = lng
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minLat)) return undefined
|
||||||
|
|
||||||
|
// Skip bias if the bounding box is too large (~500 km diagonal)
|
||||||
|
const dlat = maxLat - minLat
|
||||||
|
const dlng = maxLng - minLng
|
||||||
|
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||||
|
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
||||||
|
if (diagKm > 500) return undefined
|
||||||
|
|
||||||
|
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||||
|
}, [places])
|
||||||
|
|
||||||
|
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
||||||
|
const fetchSuggestions = useCallback(async (query: string) => {
|
||||||
|
if (query.length < 2 || isGoogleMapsUrl(query)) {
|
||||||
|
setAcSuggestions([])
|
||||||
|
setAcHighlight(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
acAbortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
acAbortRef.current = controller
|
||||||
|
try {
|
||||||
|
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal)
|
||||||
|
setAcSuggestions(result.suggestions || [])
|
||||||
|
setAcHighlight(-1)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
|
if (err instanceof Error && err.name === 'CanceledError') return // axios abort
|
||||||
|
console.error('Autocomplete failed:', err)
|
||||||
|
setAcSuggestions([])
|
||||||
|
}
|
||||||
|
}, [language, locationBias])
|
||||||
|
|
||||||
|
// Debounce effect — only watches mapsSearch
|
||||||
|
useEffect(() => {
|
||||||
|
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
||||||
|
|
||||||
|
const trimmed = mapsSearch.trim()
|
||||||
|
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
|
||||||
|
setAcSuggestions([])
|
||||||
|
setAcHighlight(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
||||||
|
}
|
||||||
|
}, [mapsSearch, fetchSuggestions])
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
const handleChange = (field, value) => {
|
||||||
setForm(prev => ({ ...prev, [field]: value }))
|
setForm(prev => ({ ...prev, [field]: value }))
|
||||||
}
|
}
|
||||||
@@ -111,7 +201,7 @@ export default function PlaceFormModal({
|
|||||||
try {
|
try {
|
||||||
// Detect Google Maps URLs and resolve them directly
|
// Detect Google Maps URLs and resolve them directly
|
||||||
const trimmed = mapsSearch.trim()
|
const trimmed = mapsSearch.trim()
|
||||||
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
|
if (isGoogleMapsUrl(trimmed)) {
|
||||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||||
if (resolved.lat && resolved.lng) {
|
if (resolved.lat && resolved.lng) {
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
@@ -152,6 +242,56 @@ export default function PlaceFormModal({
|
|||||||
setMapsSearch('')
|
setMapsSearch('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||||
|
setAcSuggestions([])
|
||||||
|
setAcHighlight(-1)
|
||||||
|
const previousSearch = mapsSearch
|
||||||
|
setMapsSearch('')
|
||||||
|
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||||
|
setIsSearchingMaps(true)
|
||||||
|
try {
|
||||||
|
const result = await mapsApi.details(suggestion.placeId, language)
|
||||||
|
if (result.place) {
|
||||||
|
handleSelectMapsResult(result.place)
|
||||||
|
} else {
|
||||||
|
setMapsSearch(previousSearch)
|
||||||
|
toast.error(t('places.mapsSearchError'))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch place details:', err)
|
||||||
|
setMapsSearch(previousSearch)
|
||||||
|
toast.error(t('places.mapsSearchError'))
|
||||||
|
} finally {
|
||||||
|
setIsSearchingMaps(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (acSuggestions.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setAcHighlight(prev => (prev + 1) % acSuggestions.length)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (acHighlight >= 0) {
|
||||||
|
handleSelectSuggestion(acSuggestions[acHighlight])
|
||||||
|
} else {
|
||||||
|
setAcSuggestions([])
|
||||||
|
handleMapsSearch()
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setAcSuggestions([])
|
||||||
|
setAcHighlight(-1)
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleMapsSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateCategory = async () => {
|
const handleCreateCategory = async () => {
|
||||||
if (!newCategoryName.trim()) return
|
if (!newCategoryName.trim()) return
|
||||||
try {
|
try {
|
||||||
@@ -229,25 +369,56 @@ export default function PlaceFormModal({
|
|||||||
{t('places.osmActive')}
|
{t('places.osmActive')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
<div className="relative">
|
||||||
<input
|
<div className="flex gap-2">
|
||||||
type="text"
|
<input
|
||||||
value={mapsSearch}
|
type="text"
|
||||||
onChange={e => setMapsSearch(e.target.value)}
|
value={mapsSearch}
|
||||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
|
onChange={e => setMapsSearch(e.target.value)}
|
||||||
placeholder={t('places.mapsSearchPlaceholder')}
|
onKeyDown={handleSearchKeyDown}
|
||||||
autoFocus
|
onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
|
||||||
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
onFocus={() => {
|
||||||
/>
|
if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
|
||||||
<button
|
fetchSuggestions(mapsSearch.trim())
|
||||||
type="button"
|
}
|
||||||
onClick={handleMapsSearch}
|
}}
|
||||||
disabled={isSearchingMaps}
|
placeholder={t('places.mapsSearchPlaceholder')}
|
||||||
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
|
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||||
>
|
/>
|
||||||
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={() => { setAcSuggestions([]); handleMapsSearch() }}
|
||||||
|
disabled={isSearchingMaps}
|
||||||
|
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Autocomplete dropdown */}
|
||||||
|
{acSuggestions.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 z-20 mt-1 bg-white rounded-lg border border-slate-200 shadow-lg overflow-hidden">
|
||||||
|
{acSuggestions.map((s, idx) => (
|
||||||
|
<button
|
||||||
|
key={s.placeId}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={() => handleSelectSuggestion(s)}
|
||||||
|
onMouseEnter={() => setAcHighlight(idx)}
|
||||||
|
className={`w-full text-left px-3 py-2 border-b border-slate-100 last:border-0 ${
|
||||||
|
idx === acHighlight ? 'bg-slate-100' : 'hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{s.mainText}</div>
|
||||||
|
{s.secondaryText && (
|
||||||
|
<div className="text-xs text-slate-500 truncate">{s.secondaryText}</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search results (populated after full search) */}
|
||||||
{mapsResults.length > 0 && (
|
{mapsResults.length > 0 && (
|
||||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
|
||||||
{mapsResults.map((result, idx) => (
|
{mapsResults.map((result, idx) => (
|
||||||
@@ -268,14 +439,21 @@ export default function PlaceFormModal({
|
|||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<input
|
||||||
value={form.name}
|
type="text"
|
||||||
onChange={e => handleChange('name', e.target.value)}
|
value={form.name}
|
||||||
required
|
onChange={e => handleChange('name', e.target.value)}
|
||||||
placeholder={t('places.formNamePlaceholder')}
|
required
|
||||||
className="form-input"
|
placeholder={t('places.formNamePlaceholder')}
|
||||||
/>
|
className="form-input"
|
||||||
|
/>
|
||||||
|
{isSearchingMaps && (
|
||||||
|
<div className="absolute right-2.5 top-0 bottom-0 flex items-center" role="status" aria-label={t('places.loadingDetails')}>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-slate-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|||||||
@@ -936,6 +936,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
|
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
|
||||||
'places.mapsSearchPlaceholder': 'ابحث عن أماكن...',
|
'places.mapsSearchPlaceholder': 'ابحث عن أماكن...',
|
||||||
'places.mapsSearchError': 'فشل البحث عن المكان.',
|
'places.mapsSearchError': 'فشل البحث عن المكان.',
|
||||||
|
'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…',
|
||||||
'places.osmHint': 'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
|
'places.osmHint': 'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
|
||||||
'places.osmActive': 'البحث عبر OpenStreetMap (بدون صور أو تقييمات أو ساعات عمل). أضف مفتاح Google API في الإعدادات لبيانات موسعة.',
|
'places.osmActive': 'البحث عبر OpenStreetMap (بدون صور أو تقييمات أو ساعات عمل). أضف مفتاح Google API في الإعدادات لبيانات موسعة.',
|
||||||
'places.categoryCreateError': 'فشل إنشاء الفئة',
|
'places.categoryCreateError': 'فشل إنشاء الفئة',
|
||||||
|
|||||||
@@ -906,6 +906,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
|
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
|
||||||
'places.mapsSearchPlaceholder': 'Buscar lugares...',
|
'places.mapsSearchPlaceholder': 'Buscar lugares...',
|
||||||
'places.mapsSearchError': 'Falha na busca de lugares.',
|
'places.mapsSearchError': 'Falha na busca de lugares.',
|
||||||
|
'places.loadingDetails': 'Carregando detalhes do lugar…',
|
||||||
'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
|
'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
|
||||||
'places.osmActive': 'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.',
|
'places.osmActive': 'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.',
|
||||||
'places.categoryCreateError': 'Falha ao criar categoria',
|
'places.categoryCreateError': 'Falha ao criar categoria',
|
||||||
|
|||||||
@@ -934,6 +934,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
|
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
|
||||||
'places.mapsSearchPlaceholder': 'Hledat místa...',
|
'places.mapsSearchPlaceholder': 'Hledat místa...',
|
||||||
'places.mapsSearchError': 'Hledání místa se nezdařilo.',
|
'places.mapsSearchError': 'Hledání místa se nezdařilo.',
|
||||||
|
'places.loadingDetails': 'Načítání podrobností místa…',
|
||||||
'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
|
'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
|
||||||
'places.osmActive': 'Hledání přes OpenStreetMap.',
|
'places.osmActive': 'Hledání přes OpenStreetMap.',
|
||||||
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
|
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
|
||||||
|
|||||||
@@ -937,6 +937,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
|
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
|
||||||
'places.mapsSearchPlaceholder': 'Ortssuche...',
|
'places.mapsSearchPlaceholder': 'Ortssuche...',
|
||||||
'places.mapsSearchError': 'Ortssuche fehlgeschlagen.',
|
'places.mapsSearchError': 'Ortssuche fehlgeschlagen.',
|
||||||
|
'places.loadingDetails': 'Ortsdetails werden geladen…',
|
||||||
'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
|
'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
|
||||||
'places.osmActive': 'Suche via OpenStreetMap (ohne Bilder, Bewertungen & Öffnungszeiten). Google API Key in den Einstellungen hinterlegen für erweiterte Daten.',
|
'places.osmActive': 'Suche via OpenStreetMap (ohne Bilder, Bewertungen & Öffnungszeiten). Google API Key in den Einstellungen hinterlegen für erweiterte Daten.',
|
||||||
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
|
||||||
|
|||||||
@@ -959,6 +959,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
|
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
|
||||||
'places.mapsSearchPlaceholder': 'Search places...',
|
'places.mapsSearchPlaceholder': 'Search places...',
|
||||||
'places.mapsSearchError': 'Place search failed.',
|
'places.mapsSearchError': 'Place search failed.',
|
||||||
|
'places.loadingDetails': 'Loading place details…',
|
||||||
'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
|
'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
|
||||||
'places.osmActive': 'Search via OpenStreetMap (no photos, ratings or opening hours). Add a Google API key in Settings for enhanced data.',
|
'places.osmActive': 'Search via OpenStreetMap (no photos, ratings or opening hours). Add a Google API key in Settings for enhanced data.',
|
||||||
'places.categoryCreateError': 'Failed to create category',
|
'places.categoryCreateError': 'Failed to create category',
|
||||||
|
|||||||
@@ -909,6 +909,7 @@ const es: Record<string, string> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
|
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
|
||||||
'places.mapsSearchPlaceholder': 'Buscar lugares...',
|
'places.mapsSearchPlaceholder': 'Buscar lugares...',
|
||||||
'places.mapsSearchError': 'La búsqueda de lugares falló.',
|
'places.mapsSearchError': 'La búsqueda de lugares falló.',
|
||||||
|
'places.loadingDetails': 'Cargando detalles del lugar…',
|
||||||
'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
|
'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
|
||||||
'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.',
|
'places.osmActive': 'Búsqueda mediante OpenStreetMap (sin fotos, valoraciones ni horarios). Añade una clave API de Google en Ajustes para datos ampliados.',
|
||||||
'places.categoryCreateError': 'No se pudo crear la categoría',
|
'places.categoryCreateError': 'No se pudo crear la categoría',
|
||||||
|
|||||||
@@ -933,6 +933,7 @@ const fr: Record<string, string> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
|
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
|
||||||
'places.mapsSearchPlaceholder': 'Rechercher des lieux…',
|
'places.mapsSearchPlaceholder': 'Rechercher des lieux…',
|
||||||
'places.mapsSearchError': 'La recherche de lieu a échoué.',
|
'places.mapsSearchError': 'La recherche de lieu a échoué.',
|
||||||
|
'places.loadingDetails': 'Chargement des détails du lieu…',
|
||||||
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
|
'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
|
||||||
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
|
'places.osmActive': 'Recherche via OpenStreetMap (pas de photos, notes ni horaires). Ajoutez une clé API Google dans les paramètres pour des données enrichies.',
|
||||||
'places.categoryCreateError': 'Impossible de créer la catégorie',
|
'places.categoryCreateError': 'Impossible de créer la catégorie',
|
||||||
|
|||||||
@@ -934,6 +934,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
|
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
|
||||||
'places.mapsSearchPlaceholder': 'Helyek keresése...',
|
'places.mapsSearchPlaceholder': 'Helyek keresése...',
|
||||||
'places.mapsSearchError': 'Helykeresés sikertelen.',
|
'places.mapsSearchError': 'Helykeresés sikertelen.',
|
||||||
|
'places.loadingDetails': 'Hely adatainak betöltése…',
|
||||||
'places.osmHint': 'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
|
'places.osmHint': 'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
|
||||||
'places.osmActive': 'Keresés OpenStreetMap-en keresztül (képek, értékelések és nyitvatartás nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
|
'places.osmActive': 'Keresés OpenStreetMap-en keresztül (képek, értékelések és nyitvatartás nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
|
||||||
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
|
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
|
||||||
|
|||||||
@@ -934,6 +934,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
|
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
|
||||||
'places.mapsSearchPlaceholder': 'Cerca luoghi...',
|
'places.mapsSearchPlaceholder': 'Cerca luoghi...',
|
||||||
'places.mapsSearchError': 'Impossibile cercare i luoghi.',
|
'places.mapsSearchError': 'Impossibile cercare i luoghi.',
|
||||||
|
'places.loadingDetails': 'Caricamento dettagli del luogo…',
|
||||||
'places.osmHint': 'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.',
|
'places.osmHint': 'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.',
|
||||||
'places.osmActive': 'Ricerca tramite OpenStreetMap (senza foto, valutazioni o orari di apertura). Aggiungi una chiave API Google nelle Impostazioni per dati avanzati.',
|
'places.osmActive': 'Ricerca tramite OpenStreetMap (senza foto, valutazioni o orari di apertura). Aggiungi una chiave API Google nelle Impostazioni per dati avanzati.',
|
||||||
'places.categoryCreateError': 'Impossibile creare la categoria',
|
'places.categoryCreateError': 'Impossibile creare la categoria',
|
||||||
|
|||||||
@@ -933,6 +933,7 @@ const nl: Record<string, string> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
|
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
|
||||||
'places.mapsSearchPlaceholder': 'Plaatsen zoeken...',
|
'places.mapsSearchPlaceholder': 'Plaatsen zoeken...',
|
||||||
'places.mapsSearchError': 'Zoeken naar plaatsen mislukt.',
|
'places.mapsSearchError': 'Zoeken naar plaatsen mislukt.',
|
||||||
|
'places.loadingDetails': 'Plaatsgegevens laden…',
|
||||||
'places.osmHint': 'Zoeken via OpenStreetMap (geen foto\'s, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.',
|
'places.osmHint': 'Zoeken via OpenStreetMap (geen foto\'s, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.',
|
||||||
'places.osmActive': 'Zoeken via OpenStreetMap (geen foto\'s, beoordelingen of openingstijden). Voeg een Google API-sleutel toe in Instellingen voor uitgebreide gegevens.',
|
'places.osmActive': 'Zoeken via OpenStreetMap (geen foto\'s, beoordelingen of openingstijden). Voeg een Google API-sleutel toe in Instellingen voor uitgebreide gegevens.',
|
||||||
'places.categoryCreateError': 'Categorie aanmaken mislukt',
|
'places.categoryCreateError': 'Categorie aanmaken mislukt',
|
||||||
|
|||||||
@@ -895,6 +895,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...',
|
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...',
|
||||||
'places.mapsSearchPlaceholder': 'Szukaj miejsc...',
|
'places.mapsSearchPlaceholder': 'Szukaj miejsc...',
|
||||||
'places.mapsSearchError': 'Nie udało się wyszukać miejsca.',
|
'places.mapsSearchError': 'Nie udało się wyszukać miejsca.',
|
||||||
|
'places.loadingDetails': 'Ładowanie szczegółów miejsca…',
|
||||||
'places.osmHint': 'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
|
'places.osmHint': 'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
|
||||||
'places.osmActive': 'Szukaj przez OpenStreetMap (brak zdjęć, ocen czy godzin otwarcia). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
|
'places.osmActive': 'Szukaj przez OpenStreetMap (brak zdjęć, ocen czy godzin otwarcia). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
|
||||||
'places.categoryCreateError': 'Nie udało się utworzyć kategorii',
|
'places.categoryCreateError': 'Nie udało się utworzyć kategorii',
|
||||||
|
|||||||
@@ -933,6 +933,7 @@ const ru: Record<string, string> = {
|
|||||||
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
|
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
|
||||||
'places.mapsSearchPlaceholder': 'Поиск мест...',
|
'places.mapsSearchPlaceholder': 'Поиск мест...',
|
||||||
'places.mapsSearchError': 'Ошибка поиска мест.',
|
'places.mapsSearchError': 'Ошибка поиска мест.',
|
||||||
|
'places.loadingDetails': 'Загрузка данных о месте…',
|
||||||
'places.osmHint': 'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.',
|
'places.osmHint': 'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.',
|
||||||
'places.osmActive': 'Поиск через OpenStreetMap (без фото, рейтингов и часов работы). Добавьте API-ключ Google в настройках для расширенных данных.',
|
'places.osmActive': 'Поиск через OpenStreetMap (без фото, рейтингов и часов работы). Добавьте API-ключ Google в настройках для расширенных данных.',
|
||||||
'places.categoryCreateError': 'Не удалось создать категорию',
|
'places.categoryCreateError': 'Не удалось создать категорию',
|
||||||
|
|||||||
@@ -933,6 +933,7 @@ const zh: Record<string, string> = {
|
|||||||
'places.reservationNotesPlaceholder': '预订备注、确认号...',
|
'places.reservationNotesPlaceholder': '预订备注、确认号...',
|
||||||
'places.mapsSearchPlaceholder': '搜索地点...',
|
'places.mapsSearchPlaceholder': '搜索地点...',
|
||||||
'places.mapsSearchError': '地点搜索失败。',
|
'places.mapsSearchError': '地点搜索失败。',
|
||||||
|
'places.loadingDetails': '正在加载地点详情…',
|
||||||
'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。',
|
'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。',
|
||||||
'places.osmActive': '通过 OpenStreetMap 搜索(无照片、评分或营业时间)。在设置中添加 Google API 密钥以获取增强数据。',
|
'places.osmActive': '通过 OpenStreetMap 搜索(无照片、评分或营业时间)。在设置中添加 Google API 密钥以获取增强数据。',
|
||||||
'places.categoryCreateError': '创建分类失败',
|
'places.categoryCreateError': '创建分类失败',
|
||||||
|
|||||||
@@ -958,6 +958,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
|
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
|
||||||
'places.mapsSearchPlaceholder': '搜尋地點...',
|
'places.mapsSearchPlaceholder': '搜尋地點...',
|
||||||
'places.mapsSearchError': '地點搜尋失敗。',
|
'places.mapsSearchError': '地點搜尋失敗。',
|
||||||
|
'places.loadingDetails': '正在載入地點詳情…',
|
||||||
'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。',
|
'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。',
|
||||||
'places.osmActive': '透過 OpenStreetMap 搜尋(無照片、評分或營業時間)。在設定中新增 Google API 金鑰以獲取增強資料。',
|
'places.osmActive': '透過 OpenStreetMap 搜尋(無照片、評分或營業時間)。在設定中新增 Google API 金鑰以獲取增強資料。',
|
||||||
'places.categoryCreateError': '建立分類失敗',
|
'places.categoryCreateError': '建立分類失敗',
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await authApi.updateMapsKey(key)
|
await authApi.updateMapsKey(key)
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
|
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
|
||||||
|
hasMapsKey: !!key,
|
||||||
}))
|
}))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
|
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
|
||||||
@@ -204,6 +205,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
const data = await authApi.updateApiKeys(keys)
|
const data = await authApi.updateApiKeys(keys)
|
||||||
set({ user: data.user })
|
set({ user: data.user })
|
||||||
|
if ('maps_api_key' in keys) {
|
||||||
|
set({ hasMapsKey: !!keys.maps_api_key })
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
|
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -904,3 +904,71 @@ describe('API namespace smoke tests', () => {
|
|||||||
await expect(backupApi.create()).resolves.toMatchObject({ filename: 'backup.zip' });
|
await expect(backupApi.create()).resolves.toMatchObject({ filename: 'backup.zip' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mapsApi', () => {
|
||||||
|
it('FE-MAPS-001: mapsApi.autocomplete sends input, lang, and locationBias', async () => {
|
||||||
|
let capturedBody: any = null;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||||
|
capturedBody = await request.json();
|
||||||
|
return HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' }],
|
||||||
|
source: 'google',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await mapsApi.autocomplete('Par', 'fr', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
|
||||||
|
|
||||||
|
expect(capturedBody).toEqual({
|
||||||
|
input: 'Par',
|
||||||
|
lang: 'fr',
|
||||||
|
locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
|
||||||
|
});
|
||||||
|
expect(result.suggestions).toHaveLength(1);
|
||||||
|
expect(result.suggestions[0].mainText).toBe('Paris');
|
||||||
|
expect(result.source).toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MAPS-002: mapsApi.autocomplete works without optional params', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', async ({ request }) => {
|
||||||
|
const body: any = await request.json();
|
||||||
|
expect(body.lang).toBeUndefined();
|
||||||
|
expect(body.locationBias).toBeUndefined();
|
||||||
|
return HttpResponse.json({ suggestions: [], source: 'nominatim' });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await mapsApi.autocomplete('test');
|
||||||
|
expect(result.suggestions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MAPS-003: mapsApi.autocomplete rejects on server error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () => {
|
||||||
|
return HttpResponse.json({ error: 'Rate limited' }, { status: 429 });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(mapsApi.autocomplete('test')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-MAPS-004: mapsApi.autocomplete rejects when AbortSignal is aborted', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', async () => {
|
||||||
|
// Never resolves — request will be aborted
|
||||||
|
await new Promise(() => {});
|
||||||
|
return HttpResponse.json({ suggestions: [] });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const promise = mapsApi.autocomplete('Paris', undefined, undefined, controller.signal);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Generated
+10
@@ -1590,6 +1590,7 @@
|
|||||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^4.17.33",
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
@@ -2151,6 +2152,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
|
||||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"bare-abort-controller": "*"
|
"bare-abort-controller": "*"
|
||||||
},
|
},
|
||||||
@@ -3162,6 +3164,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
@@ -3669,6 +3672,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -5726,6 +5730,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5800,6 +5805,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -5955,6 +5961,7 @@
|
|||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6071,6 +6078,7 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6084,6 +6092,7 @@
|
|||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@@ -6322,6 +6331,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getPlacePhoto,
|
getPlacePhoto,
|
||||||
reverseGeocode,
|
reverseGeocode,
|
||||||
resolveGoogleMapsUrl,
|
resolveGoogleMapsUrl,
|
||||||
|
autocompletePlaces,
|
||||||
} from '../services/mapsService';
|
} from '../services/mapsService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -29,6 +30,44 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /autocomplete
|
||||||
|
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { input, lang, locationBias } = req.body;
|
||||||
|
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Input is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.length > 200) {
|
||||||
|
return res.status(400).json({ error: 'Input too long (max 200 chars)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationBias) {
|
||||||
|
const { low, high } = locationBias;
|
||||||
|
if (!low || !high
|
||||||
|
|| !Number.isFinite(low.lat) || !Number.isFinite(low.lng)
|
||||||
|
|| !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await autocompletePlaces(
|
||||||
|
authReq.user.id,
|
||||||
|
input,
|
||||||
|
lang as string,
|
||||||
|
locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined,
|
||||||
|
);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status = (err as { status?: number }).status || 500;
|
||||||
|
const message = err instanceof Error ? err.message : 'Autocomplete error';
|
||||||
|
console.error('Maps autocomplete error:', err);
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /details/:placeId
|
// GET /details/:placeId
|
||||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ interface GooglePlaceResult {
|
|||||||
types?: string[];
|
types?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GoogleAutocompleteSuggestion {
|
||||||
|
placePrediction?: {
|
||||||
|
placeId: string;
|
||||||
|
structuredFormat?: {
|
||||||
|
mainText?: { text: string };
|
||||||
|
secondaryText?: { text: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface GooglePlaceDetails extends GooglePlaceResult {
|
interface GooglePlaceDetails extends GooglePlaceResult {
|
||||||
userRatingCount?: number;
|
userRatingCount?: number;
|
||||||
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
||||||
@@ -108,6 +118,34 @@ export async function searchNominatim(query: string, lang?: string) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Nominatim lookup (by OSM ID) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{
|
||||||
|
name: string; address: string; lat: number | null; lng: number | null;
|
||||||
|
} | null> {
|
||||||
|
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
osm_ids: `${typePrefix}${osmId}`,
|
||||||
|
format: 'json',
|
||||||
|
'accept-language': lang || 'en',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://nominatim.openstreetmap.org/lookup?${params}`, {
|
||||||
|
headers: { 'User-Agent': UA },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json() as NominatimResult[];
|
||||||
|
const item = data[0];
|
||||||
|
if (!item) return null;
|
||||||
|
return {
|
||||||
|
name: item.name || item.display_name?.split(',')[0] || '',
|
||||||
|
address: item.display_name || '',
|
||||||
|
lat: parseFloat(item.lat) || null,
|
||||||
|
lng: parseFloat(item.lon) || null,
|
||||||
|
};
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
||||||
|
|
||||||
export async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
export async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
|
||||||
@@ -306,6 +344,86 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
|||||||
return { places, source: 'google' };
|
return { places, source: 'google' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Autocomplete (Google or Nominatim fallback) ─────────────────────────────
|
||||||
|
|
||||||
|
export async function autocompletePlaces(
|
||||||
|
userId: number,
|
||||||
|
input: string,
|
||||||
|
lang?: string,
|
||||||
|
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
|
||||||
|
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||||
|
const apiKey = getMapsKey(userId);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return autocompleteNominatim(input, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
input,
|
||||||
|
languageCode: lang || 'en',
|
||||||
|
};
|
||||||
|
if (locationBias) {
|
||||||
|
body.locationBias = {
|
||||||
|
rectangle: {
|
||||||
|
low: { latitude: locationBias.low.lat, longitude: locationBias.low.lng },
|
||||||
|
high: { latitude: locationBias.high.lat, longitude: locationBias.high.lng },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Goog-Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
|
||||||
|
err.status = response.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = (data.suggestions || [])
|
||||||
|
.filter((s) => s.placePrediction)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((s) => ({
|
||||||
|
placeId: s.placePrediction!.placeId,
|
||||||
|
mainText: s.placePrediction!.structuredFormat?.mainText?.text || '',
|
||||||
|
secondaryText: s.placePrediction!.structuredFormat?.secondaryText?.text || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { suggestions, source: 'google' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autocompleteNominatim(
|
||||||
|
input: string,
|
||||||
|
lang?: string,
|
||||||
|
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
|
||||||
|
try {
|
||||||
|
const places = await searchNominatim(input, lang);
|
||||||
|
const suggestions = places
|
||||||
|
.filter((p) => p.osm_id && p.osm_id.includes(':') && p.osm_id.split(':')[1] !== '')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((p) => {
|
||||||
|
const parts = (p.address || '').split(',').map((s) => s.trim());
|
||||||
|
return {
|
||||||
|
placeId: p.osm_id,
|
||||||
|
mainText: p.name || parts[0] || '',
|
||||||
|
secondaryText: parts.slice(1).join(', '),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { suggestions, source: 'nominatim' };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Nominatim autocomplete failed:', err);
|
||||||
|
return { suggestions: [], source: 'nominatim' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Place details (Google or OSM) ────────────────────────────────────────────
|
// ── Place details (Google or OSM) ────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
||||||
@@ -313,8 +431,23 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
|||||||
if (placeId.includes(':')) {
|
if (placeId.includes(':')) {
|
||||||
const [osmType, osmId] = placeId.split(':');
|
const [osmType, osmId] = placeId.split(':');
|
||||||
const element = await fetchOverpassDetails(osmType, osmId);
|
const element = await fetchOverpassDetails(osmType, osmId);
|
||||||
if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) };
|
const details = buildOsmDetails(element?.tags || {}, osmType, osmId);
|
||||||
return { place: buildOsmDetails(element.tags, osmType, osmId) };
|
|
||||||
|
// Fetch Nominatim only when Overpass lacks coordinates or address
|
||||||
|
const d = details as Record<string, unknown>;
|
||||||
|
const needsNominatim = !d.lat || !d.lng || !d.address;
|
||||||
|
const nominatim = needsNominatim ? await lookupNominatim(osmType, osmId, lang) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
place: {
|
||||||
|
...details,
|
||||||
|
name: (d.name as string) || nominatim?.name || element?.tags?.name || '',
|
||||||
|
address: (d.address as string) || nominatim?.address || '',
|
||||||
|
lat: d.lat ?? nominatim?.lat ?? null,
|
||||||
|
lng: d.lng ?? nominatim?.lng ?? null,
|
||||||
|
osm_id: placeId,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google details
|
// Google details
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ vi.mock('../../src/config', () => ({
|
|||||||
// URLs that look internal); individual tests override with mockResolvedValueOnce.
|
// URLs that look internal); individual tests override with mockResolvedValueOnce.
|
||||||
vi.mock('../../src/services/mapsService', () => ({
|
vi.mock('../../src/services/mapsService', () => ({
|
||||||
searchPlaces: vi.fn(),
|
searchPlaces: vi.fn(),
|
||||||
|
autocompletePlaces: vi.fn(),
|
||||||
getPlaceDetails: vi.fn(),
|
getPlaceDetails: vi.fn(),
|
||||||
getPlacePhoto: vi.fn(),
|
getPlacePhoto: vi.fn(),
|
||||||
reverseGeocode: vi.fn(),
|
reverseGeocode: vi.fn(),
|
||||||
@@ -278,3 +279,120 @@ describe('Maps happy paths (mocked service)', () => {
|
|||||||
expect(res.body.address).toBeNull();
|
expect(res.body.address).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Maps autocomplete', () => {
|
||||||
|
it('MAPS-009 — POST /maps/autocomplete without auth returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.send({ input: 'Paris' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-010 — POST /maps/autocomplete without input returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-011 — POST /maps/autocomplete with non-string input returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 123 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-012 — POST /maps/autocomplete with invalid locationBias returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 'Paris', locationBias: { low: { lat: NaN, lng: 2.3 }, high: { lat: 49, lng: 3 } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-013 — POST /maps/autocomplete returns suggestions from service', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({
|
||||||
|
suggestions: [
|
||||||
|
{ placeId: 'ChIJ1234', mainText: 'Paris', secondaryText: 'France' },
|
||||||
|
],
|
||||||
|
source: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 'Paris' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.suggestions).toHaveLength(1);
|
||||||
|
expect(res.body.suggestions[0].mainText).toBe('Paris');
|
||||||
|
expect(res.body.source).toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-014 — POST /maps/autocomplete passes lang and locationBias to service', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
vi.mocked(mapsService.autocompletePlaces).mockResolvedValueOnce({
|
||||||
|
suggestions: [],
|
||||||
|
source: 'google',
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 'test', lang: 'fr', locationBias: { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } } });
|
||||||
|
|
||||||
|
expect(mapsService.autocompletePlaces).toHaveBeenCalledWith(
|
||||||
|
user.id,
|
||||||
|
'test',
|
||||||
|
'fr',
|
||||||
|
{ low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-015 — autocomplete service error propagates correct status', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const err = Object.assign(new Error('Rate limited'), { status: 429 });
|
||||||
|
vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(err);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body.error).toBe('Rate limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-016 — autocomplete service error without status returns 500', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
vi.mocked(mapsService.autocompletePlaces).mockRejectedValueOnce(new Error('Unknown'));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 'test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-017 — POST /maps/autocomplete with input > 200 chars returns 400', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/maps/autocomplete')
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ input: 'a'.repeat(201) });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/too long/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -714,6 +714,217 @@ describe('searchPlaces (fetch stubbed)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── autocompletePlaces (fetch stubbed) ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('autocompletePlaces (fetch stubbed)', () => {
|
||||||
|
it('MAPS-081: uses Nominatim when user has no API key', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [
|
||||||
|
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, Île-de-France, France', name: 'Paris' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(999, 'Paris');
|
||||||
|
expect(result.source).toBe('nominatim');
|
||||||
|
expect(result.suggestions).toHaveLength(1);
|
||||||
|
expect(result.suggestions[0].mainText).toBe('Paris');
|
||||||
|
expect(result.suggestions[0].placeId).toBe('node:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-082: uses Google when user has an API key', async () => {
|
||||||
|
mockDbGet
|
||||||
|
.mockReturnValueOnce({ maps_api_key: 'ENCRYPTED' })
|
||||||
|
.mockReturnValueOnce(null);
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
placePrediction: {
|
||||||
|
placeId: 'ChIJ1234',
|
||||||
|
structuredFormat: {
|
||||||
|
mainText: { text: 'Eiffel Tower' },
|
||||||
|
secondaryText: { text: 'Paris, France' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(1, 'Eiffel');
|
||||||
|
expect(result.source).toBe('google');
|
||||||
|
expect(result.suggestions).toHaveLength(1);
|
||||||
|
expect(result.suggestions[0].placeId).toBe('ChIJ1234');
|
||||||
|
expect(result.suggestions[0].mainText).toBe('Eiffel Tower');
|
||||||
|
expect(result.suggestions[0].secondaryText).toBe('Paris, France');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-083: throws with Google error status when API returns non-ok', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
json: async () => ({ error: { message: 'API key invalid' } }),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({
|
||||||
|
message: 'API key invalid',
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-084: throws generic message when Google error has no message', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({ error: {} }),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
await expect(autocompletePlaces(1, 'anything')).rejects.toMatchObject({
|
||||||
|
message: 'Google Places Autocomplete error',
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-085: returns empty suggestions when Google returns no results', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ suggestions: [] }),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(1, 'very obscure place');
|
||||||
|
expect(result.source).toBe('google');
|
||||||
|
expect(result.suggestions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-086: filters out suggestions without placePrediction', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
suggestions: [
|
||||||
|
{ placePrediction: { placeId: 'A', structuredFormat: { mainText: { text: 'Good' } } } },
|
||||||
|
{ queryPrediction: { text: 'some query' } },
|
||||||
|
{ placePrediction: { placeId: 'B', structuredFormat: { mainText: { text: 'Also Good' } } } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(1, 'test');
|
||||||
|
expect(result.suggestions).toHaveLength(2);
|
||||||
|
expect(result.suggestions[0].placeId).toBe('A');
|
||||||
|
expect(result.suggestions[1].placeId).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-087: limits results to 5 suggestions', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||||
|
const manySuggestions = Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
placePrediction: {
|
||||||
|
placeId: `id-${i}`,
|
||||||
|
structuredFormat: { mainText: { text: `Place ${i}` } },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ suggestions: manySuggestions }),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(1, 'test');
|
||||||
|
expect(result.suggestions).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-088: includes locationBias in Google request when provided', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ suggestions: [] }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
await autocompletePlaces(1, 'test', 'en', { low: { lat: 48.5, lng: 2.0 }, high: { lat: 49.0, lng: 2.8 } });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce();
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.locationBias).toEqual({
|
||||||
|
rectangle: {
|
||||||
|
low: { latitude: 48.5, longitude: 2.0 },
|
||||||
|
high: { latitude: 49.0, longitude: 2.8 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-089: omits locationBias from Google request when not provided', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'test-key' });
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ suggestions: [] }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
await autocompletePlaces(1, 'test', 'en');
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.locationBias).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-090: handles missing structuredFormat fields gracefully', async () => {
|
||||||
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'some-key' });
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
suggestions: [
|
||||||
|
{ placePrediction: { placeId: 'sparse-id' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(1, 'sparse');
|
||||||
|
expect(result.suggestions[0].placeId).toBe('sparse-id');
|
||||||
|
expect(result.suggestions[0].mainText).toBe('');
|
||||||
|
expect(result.suggestions[0].secondaryText).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-091: Nominatim fallback returns empty suggestions on searchNominatim error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(999, 'fail');
|
||||||
|
expect(result.source).toBe('nominatim');
|
||||||
|
expect(result.suggestions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-092: Nominatim fallback splits address into mainText and secondaryText', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [
|
||||||
|
{ osm_type: 'way', osm_id: '42', lat: '51.5', lon: '-0.1', display_name: 'Big Ben, Westminster, London, UK', name: 'Big Ben' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(999, 'Big Ben');
|
||||||
|
expect(result.suggestions[0].mainText).toBe('Big Ben');
|
||||||
|
expect(result.suggestions[0].secondaryText).toBe('Westminster, London, UK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAPS-093: Nominatim fallback filters out results with empty osm_id', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [
|
||||||
|
{ osm_type: 'node', osm_id: '1', lat: '48.8', lon: '2.3', display_name: 'Paris, France', name: 'Paris' },
|
||||||
|
{ osm_type: 'node', osm_id: '', lat: '51.5', lon: '-0.1', display_name: 'London, UK', name: 'London' },
|
||||||
|
{ osm_type: 'way', osm_id: '3', lat: '52.5', lon: '13.4', display_name: 'Berlin, Germany', name: 'Berlin' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const { autocompletePlaces } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await autocompletePlaces(999, 'test');
|
||||||
|
expect(result.suggestions).toHaveLength(2);
|
||||||
|
expect(result.suggestions.map((s) => s.placeId)).toEqual(['node:1', 'way:3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── getPlaceDetails (fetch stubbed) ─────────────────────────────────────────
|
// ── getPlaceDetails (fetch stubbed) ─────────────────────────────────────────
|
||||||
|
|
||||||
describe('getPlaceDetails (fetch stubbed)', () => {
|
describe('getPlaceDetails (fetch stubbed)', () => {
|
||||||
@@ -827,6 +1038,37 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
|||||||
expect(review.photo).toBeNull();
|
expect(review.photo).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('MAPS-040c: OSM path enriches name/address/coords from Nominatim (serial fetch)', async () => {
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
// First call: Overpass (returns element with tags but no coords)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ elements: [{ tags: { website: 'https://example.com' } }] }),
|
||||||
|
})
|
||||||
|
// Second call: Nominatim /lookup
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => [
|
||||||
|
{ osm_type: 'way', osm_id: '5', lat: '48.85', lon: '2.29', display_name: 'Eiffel Tower, Paris, France', name: 'Eiffel Tower' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||||||
|
const result = await getPlaceDetails(1, 'way:5');
|
||||||
|
const place = result.place as any;
|
||||||
|
expect(place.name).toBe('Eiffel Tower');
|
||||||
|
expect(place.address).toBe('Eiffel Tower, Paris, France');
|
||||||
|
expect(place.lat).toBeCloseTo(48.85);
|
||||||
|
expect(place.lng).toBeCloseTo(2.29);
|
||||||
|
expect(place.source).toBe('openstreetmap');
|
||||||
|
// Overpass first, then Nominatim — two total fetch calls
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const overpassUrl = fetchMock.mock.calls[0][0] as string;
|
||||||
|
const nominatimUrl = fetchMock.mock.calls[1][0] as string;
|
||||||
|
expect(overpassUrl).toContain('overpass');
|
||||||
|
expect(nominatimUrl).toContain('nominatim');
|
||||||
|
});
|
||||||
|
|
||||||
it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => {
|
it('MAPS-041e: open_now is null when regularOpeningHours.openNow is undefined', async () => {
|
||||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user