feat: add route planning, accommodation search, and map tab
- Add RouteSegment model with ORS/OSRM hybrid routing (worldwide) - Add 6 new API routes: routes, optimize, accommodation search/save, nearby POIs, isochrones - Add TripMap (MapLibre), AccommodationSearch, RouteStats frontend components - Add map tab to trip dashboard with route optimization - Migrate to basePath /rtrips for rspace.online/rtrips hosting - Add rtrips.online → rspace.online/rtrips redirect via Traefik - Install maplibre-gl and react-map-gl dependencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
28c1cd1e0d
commit
cafbc6c4c6
|
|
@ -10,3 +10,8 @@ RSPACE_INTERNAL_URL="http://rspace-online:3000"
|
|||
|
||||
# EncryptID
|
||||
NEXT_PUBLIC_ENCRYPTID_SERVER_URL="https://auth.ridentity.online"
|
||||
|
||||
# OpenRouteService (routing, optimization, isochrones)
|
||||
ORS_BASE_URL="https://routing.jeffemmett.com"
|
||||
ORS_PUBLIC_URL="https://api.openrouteservice.org"
|
||||
ORS_PUBLIC_KEY="your-free-ors-api-key"
|
||||
|
|
|
|||
|
|
@ -14,11 +14,24 @@ services:
|
|||
- RNOTES_INTERNAL_URL=${RNOTES_INTERNAL_URL:-http://rnotes-online:3000}
|
||||
- RVOTE_INTERNAL_URL=${RVOTE_INTERNAL_URL:-http://rvote-online-rvote-1:3000}
|
||||
- RCART_INTERNAL_URL=${RCART_INTERNAL_URL:-http://rcart-online:3000}
|
||||
- ORS_BASE_URL=${ORS_BASE_URL:-https://routing.jeffemmett.com}
|
||||
- ORS_PUBLIC_URL=${ORS_PUBLIC_URL:-https://api.openrouteservice.org}
|
||||
- ORS_PUBLIC_KEY=${ORS_PUBLIC_KEY:-}
|
||||
- NEXT_PUBLIC_BASE_PATH=/rtrips
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.rtrips.rule=Host(`rtrips.online`) || Host(`www.rtrips.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rtrips.online`)"
|
||||
- "traefik.http.routers.rtrips.priority=130"
|
||||
# Primary: serve app at rspace.online/rtrips (basePath handles prefix)
|
||||
- "traefik.http.routers.rtrips.rule=Host(`rspace.online`) && PathPrefix(`/rtrips`)"
|
||||
- "traefik.http.routers.rtrips.priority=115"
|
||||
- "traefik.http.services.rtrips.loadbalancer.server.port=3000"
|
||||
# Redirect: rtrips.online → rspace.online/rtrips
|
||||
- "traefik.http.routers.rtrips-redirect.rule=Host(`rtrips.online`) || Host(`www.rtrips.online`)"
|
||||
- "traefik.http.routers.rtrips-redirect.priority=130"
|
||||
- "traefik.http.routers.rtrips-redirect.middlewares=rtrips-to-rspace"
|
||||
- "traefik.http.routers.rtrips-redirect.service=rtrips"
|
||||
- "traefik.http.middlewares.rtrips-to-rspace.redirectregex.regex=^https?://(?:www\\.)?rtrips\\.online(.*)"
|
||||
- "traefik.http.middlewares.rtrips-to-rspace.redirectregex.replacement=https://rspace.online/rtrips$${1}"
|
||||
- "traefik.http.middlewares.rtrips-to-rspace.redirectregex.permanent=true"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rtrips-internal
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
basePath: '/rtrips',
|
||||
webpack: (config) => {
|
||||
config.resolve.alias['maplibre-gl'] = path.resolve(__dirname, 'node_modules/maplibre-gl/dist/maplibre-gl.js');
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@
|
|||
"name": "rtrips-online",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"maplibre-gl": "^5.21.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -26,6 +29,32 @@
|
|||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../encryptid-sdk": {
|
||||
"name": "@encryptid/sdk",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"hono": "^4.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": ">=14.0.0",
|
||||
"react": ">=18.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"next": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
|
|
@ -39,6 +68,10 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@encryptid/sdk": {
|
||||
"resolved": "../encryptid-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
|
@ -78,6 +111,111 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
|
||||
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
|
||||
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"rw": "^1.3.3",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@maplibre/geojson-vt": "^5.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "14.2.35",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz",
|
||||
|
|
@ -374,6 +512,12 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
||||
|
|
@ -412,6 +556,75 @@
|
|||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vis.gl/react-mapbox": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.0.tgz",
|
||||
"integrity": "sha512-FwvH822oxEjWYOr+pP2L8hpv+7cZB2UsQbHHHT0ryrkvvqzmTgt7qHDhamv0EobKw86e1I+B4ojENdJ5G5BkyQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"mapbox-gl": ">=3.5.0",
|
||||
"react": ">=16.3.0",
|
||||
"react-dom": ">=16.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mapbox-gl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vis.gl/react-maplibre": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.0.tgz",
|
||||
"integrity": "sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-gl-style-spec": "^19.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"maplibre-gl": ">=4.0.0",
|
||||
"react": ">=16.3.0",
|
||||
"react-dom": ">=16.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"maplibre-gl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "19.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz",
|
||||
"integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^3.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"rw": "^1.3.3",
|
||||
"sort-object": "^3.0.3"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz",
|
||||
"integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
|
|
@ -440,6 +653,24 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/arr-union": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz",
|
||||
"integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/assign-symbols": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz",
|
||||
"integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
@ -477,6 +708,25 @@
|
|||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytewise": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz",
|
||||
"integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytewise-core": "^1.2.2",
|
||||
"typewise": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/bytewise-core": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz",
|
||||
"integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"typewise-core": "^1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
|
|
@ -676,6 +926,12 @@
|
|||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/effect": {
|
||||
"version": "3.18.4",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
|
||||
|
|
@ -704,6 +960,18 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
|
|
@ -805,6 +1073,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-value": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
|
||||
"integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
|
|
@ -823,6 +1100,12 @@
|
|||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
|
|
@ -884,6 +1167,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -917,6 +1209,27 @@
|
|||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isobject": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
|
|
@ -933,6 +1246,18 @@
|
|||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
|
|
@ -965,6 +1290,40 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.0.tgz",
|
||||
"integrity": "sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.0.4",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
|
|
@ -989,6 +1348,21 @@
|
|||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
|
|
@ -1198,6 +1572,18 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
|
|
@ -1438,6 +1824,12 @@
|
|||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.19.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
||||
|
|
@ -1464,6 +1856,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
|
|
@ -1502,6 +1900,12 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
|
|
@ -1538,6 +1942,30 @@
|
|||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-map-gl": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.0.tgz",
|
||||
"integrity": "sha512-vDx/QXR3Tb+8/ap/z6gdMjJQ8ZEyaZf6+uMSPz7jhWF5VZeIsKsGfPvwHVPPwGF43Ryn+YR4bd09uEFNR5OPdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vis.gl/react-mapbox": "8.1.0",
|
||||
"@vis.gl/react-maplibre": "8.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mapbox-gl": ">=1.13.0",
|
||||
"maplibre-gl": ">=1.13.0",
|
||||
"react": ">=16.3.0",
|
||||
"react-dom": ">=16.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mapbox-gl": {
|
||||
"optional": true
|
||||
},
|
||||
"maplibre-gl": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
|
@ -1583,6 +2011,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||
|
|
@ -1618,6 +2055,12 @@
|
|||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
|
|
@ -1627,6 +2070,56 @@
|
|||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-value": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
||||
"integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"is-extendable": "^0.1.1",
|
||||
"is-plain-object": "^2.0.3",
|
||||
"split-string": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-asc": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz",
|
||||
"integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-desc": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz",
|
||||
"integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-object": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz",
|
||||
"integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytewise": "^1.1.0",
|
||||
"get-value": "^2.0.2",
|
||||
"is-extendable": "^0.1.1",
|
||||
"sort-asc": "^0.2.0",
|
||||
"sort-desc": "^0.2.0",
|
||||
"union-value": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -1636,6 +2129,43 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split-string": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
||||
"integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split-string/node_modules/extend-shallow": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
|
||||
"integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assign-symbols": "^1.0.0",
|
||||
"is-extendable": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split-string/node_modules/is-extendable": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz",
|
||||
"integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-plain-object": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
|
|
@ -1690,6 +2220,15 @@
|
|||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
|
|
@ -1883,6 +2422,12 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
|
|
@ -1923,6 +2468,21 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typewise": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz",
|
||||
"integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"typewise-core": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typewise-core": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz",
|
||||
"integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
|
@ -1930,6 +2490,21 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/union-value": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz",
|
||||
"integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"arr-union": "^3.1.0",
|
||||
"get-value": "^2.0.6",
|
||||
"is-extendable": "^0.1.1",
|
||||
"set-value": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@
|
|||
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"maplibre-gl": "^5.21.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"next": "14.2.35",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-map-gl": "^8.1.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ model Trip {
|
|||
bookings Booking[]
|
||||
expenses Expense[]
|
||||
packingItems PackingItem[]
|
||||
routeSegments RouteSegment[]
|
||||
}
|
||||
|
||||
enum TripStatus {
|
||||
|
|
@ -93,6 +94,8 @@ model Destination {
|
|||
|
||||
itineraryItems ItineraryItem[]
|
||||
bookings Booking[]
|
||||
segmentsFrom RouteSegment[] @relation("SegmentFrom")
|
||||
segmentsTo RouteSegment[] @relation("SegmentTo")
|
||||
|
||||
@@index([tripId])
|
||||
}
|
||||
|
|
@ -223,3 +226,23 @@ model PackingItem {
|
|||
|
||||
@@index([tripId])
|
||||
}
|
||||
|
||||
// ─── Route Segments ────────────────────────────────────────────────
|
||||
|
||||
model RouteSegment {
|
||||
id String @id @default(cuid())
|
||||
tripId String
|
||||
trip Trip @relation(fields: [tripId], references: [id], onDelete: Cascade)
|
||||
fromDestId String
|
||||
fromDest Destination @relation("SegmentFrom", fields: [fromDestId], references: [id], onDelete: Cascade)
|
||||
toDestId String
|
||||
toDest Destination @relation("SegmentTo", fields: [toDestId], references: [id], onDelete: Cascade)
|
||||
profile String @default("driving-car")
|
||||
distanceMeters Int?
|
||||
durationSeconds Int?
|
||||
geometry String? @db.Text // GeoJSON LineString
|
||||
computedAt DateTime @default(now())
|
||||
|
||||
@@unique([tripId, fromDestId, toDestId, profile])
|
||||
@@index([tripId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const role = await requireTripRole(auth.user.id, params.id, 'MEMBER');
|
||||
if (role instanceof NextResponse) return role;
|
||||
|
||||
const body = await request.json();
|
||||
const { destId, listing, checkIn, checkOut } = body;
|
||||
|
||||
if (!destId || !listing) {
|
||||
return NextResponse.json({ error: 'destId and listing are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify destination belongs to trip
|
||||
const destination = await prisma.destination.findUnique({
|
||||
where: { id: destId },
|
||||
});
|
||||
if (!destination || destination.tripId !== params.id) {
|
||||
return NextResponse.json({ error: 'Destination not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
tripId: params.id,
|
||||
destinationId: destId,
|
||||
type: 'HOTEL',
|
||||
provider: 'Airbnb',
|
||||
details: JSON.stringify(listing),
|
||||
cost: listing.price || null,
|
||||
currency: listing.currency || 'USD',
|
||||
startDate: checkIn ? new Date(checkIn) : destination.arrivalDate,
|
||||
endDate: checkOut ? new Date(checkOut) : destination.departureDate,
|
||||
status: 'PLANNED',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(booking, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Save accommodation error:', error);
|
||||
return NextResponse.json({ error: 'Failed to save accommodation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth';
|
||||
|
||||
const AIRBNB_BASE = 'https://www.airbnb.com/s/homes';
|
||||
|
||||
interface AirbnbListing {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
rating: number | null;
|
||||
reviewCount: number | null;
|
||||
roomType: string | null;
|
||||
thumbnail: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const role = await requireTripRole(auth.user.id, params.id, 'MEMBER');
|
||||
if (role instanceof NextResponse) return role;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const destId = searchParams.get('destId');
|
||||
const guests = parseInt(searchParams.get('guests') || '2');
|
||||
const maxPrice = searchParams.get('maxPrice')
|
||||
? parseInt(searchParams.get('maxPrice')!)
|
||||
: undefined;
|
||||
const currency = searchParams.get('currency') || 'USD';
|
||||
|
||||
if (!destId) {
|
||||
return NextResponse.json({ error: 'destId is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get destination and trip for context
|
||||
const destination = await prisma.destination.findUnique({
|
||||
where: { id: destId },
|
||||
include: { trip: true },
|
||||
});
|
||||
|
||||
if (!destination || destination.tripId !== params.id) {
|
||||
return NextResponse.json({ error: 'Destination not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Build search URL
|
||||
const searchQuery = `${destination.name}${destination.country ? ', ' + destination.country : ''}`;
|
||||
const urlParams = new URLSearchParams({
|
||||
query: searchQuery,
|
||||
adults: guests.toString(),
|
||||
});
|
||||
|
||||
if (destination.arrivalDate) {
|
||||
urlParams.set('checkin', destination.arrivalDate.toISOString().split('T')[0]);
|
||||
}
|
||||
if (destination.departureDate) {
|
||||
urlParams.set('checkout', destination.departureDate.toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
// Calculate smart max price from budget if not provided
|
||||
let effectiveMaxPrice = maxPrice;
|
||||
if (!effectiveMaxPrice && destination.trip.budgetTotal) {
|
||||
const destCount = await prisma.destination.count({ where: { tripId: params.id } });
|
||||
const nights = destination.arrivalDate && destination.departureDate
|
||||
? Math.max(1, Math.ceil(
|
||||
(destination.departureDate.getTime() - destination.arrivalDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
))
|
||||
: 3; // default 3 nights
|
||||
// 40% of per-destination budget for accommodation
|
||||
effectiveMaxPrice = Math.round(
|
||||
(destination.trip.budgetTotal / Math.max(destCount, 1)) * 0.4 / nights
|
||||
);
|
||||
}
|
||||
|
||||
if (effectiveMaxPrice) {
|
||||
urlParams.set('price_max', effectiveMaxPrice.toString());
|
||||
}
|
||||
|
||||
urlParams.set('currency', currency);
|
||||
|
||||
// Fetch Airbnb search results via scraping
|
||||
const searchUrl = `${AIRBNB_BASE}?${urlParams.toString()}`;
|
||||
const res = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Return empty results rather than error — Airbnb might block
|
||||
return NextResponse.json({
|
||||
listings: [] as AirbnbListing[],
|
||||
searchUrl,
|
||||
message: 'Could not fetch listings. Try the search URL directly.',
|
||||
});
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// Extract listings from Airbnb's embedded JSON data
|
||||
const listings = parseAirbnbListings(html, currency);
|
||||
|
||||
return NextResponse.json({
|
||||
listings,
|
||||
searchUrl,
|
||||
maxPrice: effectiveMaxPrice,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Accommodation search error:', error);
|
||||
return NextResponse.json({ error: 'Failed to search accommodations' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function parseAirbnbListings(html: string, currency: string): AirbnbListing[] {
|
||||
const listings: AirbnbListing[] = [];
|
||||
|
||||
try {
|
||||
// Airbnb embeds search results as JSON in a script tag
|
||||
const dataMatch = html.match(/data-deferred-state-(\d+)="([^"]+)"/);
|
||||
if (!dataMatch) {
|
||||
// Try alternative: look for __NEXT_DATA__ or bootstrapData
|
||||
const nextDataMatch = html.match(/<script[^>]*id="data-deferred-state[^"]*"[^>]*>([^<]+)<\/script>/);
|
||||
if (!nextDataMatch) return listings;
|
||||
try {
|
||||
const data = JSON.parse(nextDataMatch[1]);
|
||||
return extractListingsFromData(data, currency);
|
||||
} catch {
|
||||
return listings;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = dataMatch[2]
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
const data = JSON.parse(decoded);
|
||||
return extractListingsFromData(data, currency);
|
||||
} catch {
|
||||
return listings;
|
||||
}
|
||||
} catch {
|
||||
return listings;
|
||||
}
|
||||
}
|
||||
|
||||
function extractListingsFromData(data: Record<string, unknown>, currency: string): AirbnbListing[] {
|
||||
const listings: AirbnbListing[] = [];
|
||||
|
||||
// Walk the JSON tree looking for listing-like objects
|
||||
const walk = (obj: unknown): void => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
const o = obj as Record<string, unknown>;
|
||||
|
||||
// Detect listing objects by common Airbnb fields
|
||||
if (o.listing && typeof o.listing === 'object') {
|
||||
const l = o.listing as Record<string, unknown>;
|
||||
const id = String(l.id || '');
|
||||
if (id && !listings.find((x) => x.id === id)) {
|
||||
listings.push({
|
||||
id,
|
||||
name: String(l.name || l.title || ''),
|
||||
url: `https://www.airbnb.com/rooms/${id}`,
|
||||
price: extractPrice(o),
|
||||
currency,
|
||||
rating: typeof l.avgRating === 'number' ? l.avgRating : null,
|
||||
reviewCount: typeof l.reviewsCount === 'number' ? l.reviewsCount : null,
|
||||
roomType: typeof l.roomType === 'string' ? l.roomType : null,
|
||||
thumbnail: extractThumbnail(l),
|
||||
lat: typeof l.lat === 'number' ? l.lat : null,
|
||||
lng: typeof l.lng === 'number' ? l.lng : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Object.values(o).forEach(walk);
|
||||
};
|
||||
|
||||
walk(data);
|
||||
return listings.slice(0, 20); // cap at 20
|
||||
}
|
||||
|
||||
function extractPrice(obj: Record<string, unknown>): number | null {
|
||||
const pricing = obj.pricingQuote || obj.pricing;
|
||||
if (pricing && typeof pricing === 'object') {
|
||||
const p = pricing as Record<string, unknown>;
|
||||
const rate = p.rate || p.priceString || p.price;
|
||||
if (typeof rate === 'number') return rate;
|
||||
if (typeof rate === 'string') {
|
||||
const match = rate.match(/[\d,]+/);
|
||||
if (match) return parseInt(match[0].replace(/,/g, ''));
|
||||
}
|
||||
const structuredAmount = p.structuredStayDisplayPrice;
|
||||
if (structuredAmount && typeof structuredAmount === 'object') {
|
||||
const s = structuredAmount as Record<string, unknown>;
|
||||
const primary = s.primaryLine;
|
||||
if (primary && typeof primary === 'object') {
|
||||
const pr = primary as Record<string, unknown>;
|
||||
if (typeof pr.price === 'string') {
|
||||
const m = pr.price.match(/[\d,]+/);
|
||||
if (m) return parseInt(m[0].replace(/,/g, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractThumbnail(listing: Record<string, unknown>): string | null {
|
||||
const pics = listing.contextualPictures || listing.pictures;
|
||||
if (Array.isArray(pics) && pics.length > 0) {
|
||||
const first = pics[0];
|
||||
if (typeof first === 'string') return first;
|
||||
if (typeof first === 'object' && first) {
|
||||
const p = first as Record<string, unknown>;
|
||||
return String(p.picture || p.url || p.baseUrl || '');
|
||||
}
|
||||
}
|
||||
if (typeof listing.pictureUrl === 'string') return listing.pictureUrl;
|
||||
if (typeof listing.thumbnail === 'string') return listing.thumbnail;
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth';
|
||||
import { getIsochrone } from '@/lib/ors';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string; destId: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const role = await requireTripRole(auth.user.id, params.id, 'MEMBER');
|
||||
if (role instanceof NextResponse) return role;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const minutes = parseInt(searchParams.get('minutes') || '30');
|
||||
const profile = searchParams.get('profile') || 'driving-car';
|
||||
|
||||
const destination = await prisma.destination.findUnique({
|
||||
where: { id: params.destId },
|
||||
});
|
||||
|
||||
if (!destination || destination.tripId !== params.id) {
|
||||
return NextResponse.json({ error: 'Destination not found' }, { status: 404 });
|
||||
}
|
||||
if (!destination.lat || !destination.lng) {
|
||||
return NextResponse.json({ error: 'Destination has no coordinates' }, { status: 400 });
|
||||
}
|
||||
|
||||
const geojson = await getIsochrone(
|
||||
[destination.lng, destination.lat],
|
||||
minutes,
|
||||
profile
|
||||
);
|
||||
|
||||
return NextResponse.json(geojson);
|
||||
} catch (error) {
|
||||
console.error('Isochrone error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get isochrone' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth';
|
||||
|
||||
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
|
||||
|
||||
const CATEGORY_TAGS: Record<string, string> = {
|
||||
restaurant: '["amenity"="restaurant"]',
|
||||
cafe: '["amenity"="cafe"]',
|
||||
museum: '["tourism"="museum"]',
|
||||
park: '["leisure"="park"]',
|
||||
hotel: '["tourism"="hotel"]',
|
||||
supermarket: '["shop"="supermarket"]',
|
||||
pharmacy: '["amenity"="pharmacy"]',
|
||||
atm: '["amenity"="atm"]',
|
||||
hospital: '["amenity"="hospital"]',
|
||||
};
|
||||
|
||||
interface POI {
|
||||
name: string;
|
||||
type: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string; destId: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const role = await requireTripRole(auth.user.id, params.id, 'MEMBER');
|
||||
if (role instanceof NextResponse) return role;
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const category = searchParams.get('category') || 'restaurant';
|
||||
const radius = parseInt(searchParams.get('radius') || '2000');
|
||||
|
||||
const destination = await prisma.destination.findUnique({
|
||||
where: { id: params.destId },
|
||||
});
|
||||
|
||||
if (!destination || destination.tripId !== params.id) {
|
||||
return NextResponse.json({ error: 'Destination not found' }, { status: 404 });
|
||||
}
|
||||
if (!destination.lat || !destination.lng) {
|
||||
return NextResponse.json({ error: 'Destination has no coordinates' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tag = CATEGORY_TAGS[category] || `["amenity"="${category}"]`;
|
||||
const query = `
|
||||
[out:json][timeout:10];
|
||||
(
|
||||
node${tag}(around:${radius},${destination.lat},${destination.lng});
|
||||
way${tag}(around:${radius},${destination.lat},${destination.lng});
|
||||
);
|
||||
out center tags 20;
|
||||
`;
|
||||
|
||||
const res = await fetch(OVERPASS_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: 'Overpass API error' }, { status: 502 });
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const pois: POI[] = (data.elements || [])
|
||||
.map((el: Record<string, unknown>) => {
|
||||
const lat = (el.lat as number) || ((el.center as Record<string, number>)?.lat);
|
||||
const lng = (el.lon as number) || ((el.center as Record<string, number>)?.lon);
|
||||
if (!lat || !lng) return null;
|
||||
|
||||
const tags = el.tags as Record<string, string> | undefined;
|
||||
const name = tags?.name || tags?.['name:en'] || category;
|
||||
const distance = haversine(destination.lat!, destination.lng!, lat, lng);
|
||||
|
||||
return { name, type: category, lat, lng, distance: Math.round(distance) };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a: POI, b: POI) => a.distance - b.distance);
|
||||
|
||||
return NextResponse.json({ pois });
|
||||
} catch (error) {
|
||||
console.error('Nearby POI error:', error);
|
||||
return NextResponse.json({ error: 'Failed to find nearby places' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function haversine(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371000; // meters
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth';
|
||||
import { optimizeOrder } from '@/lib/ors';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const role = await requireTripRole(auth.user.id, params.id, 'EDITOR');
|
||||
if (role instanceof NextResponse) return role;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const profile = body.profile || 'driving-car';
|
||||
|
||||
const destinations = await prisma.destination.findMany({
|
||||
where: { tripId: params.id, lat: { not: null }, lng: { not: null } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
if (destinations.length < 3) {
|
||||
return NextResponse.json({ error: 'Need at least 3 destinations to optimize' }, { status: 400 });
|
||||
}
|
||||
|
||||
const coords = destinations.map(
|
||||
(d) => [d.lng!, d.lat!] as [number, number]
|
||||
);
|
||||
|
||||
const result = await optimizeOrder(coords, profile);
|
||||
|
||||
// Update sortOrder for each destination based on optimized order
|
||||
await prisma.$transaction(
|
||||
result.order.map((origIdx, newIdx) =>
|
||||
prisma.destination.update({
|
||||
where: { id: destinations[origIdx].id },
|
||||
data: { sortOrder: newIdx },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Delete stale route segments — they'll be recomputed on next GET
|
||||
await prisma.routeSegment.deleteMany({
|
||||
where: { tripId: params.id, profile },
|
||||
});
|
||||
|
||||
const newOrder = result.order.map((i) => ({
|
||||
id: destinations[i].id,
|
||||
name: destinations[i].name,
|
||||
sortOrder: result.order.indexOf(i),
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
newOrder,
|
||||
savings: { distance: result.distance, duration: result.duration },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Optimize route error:', error);
|
||||
return NextResponse.json({ error: 'Failed to optimize route' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth';
|
||||
import { getRoute } from '@/lib/ors';
|
||||
|
||||
const STALE_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const auth = await requireAuth(request);
|
||||
if (!isAuthed(auth)) return auth;
|
||||
|
||||
const role = await requireTripRole(auth.user.id, params.id, 'MEMBER');
|
||||
if (role instanceof NextResponse) return role;
|
||||
|
||||
const profile = request.nextUrl.searchParams.get('profile') || 'driving-car';
|
||||
|
||||
// Get destinations with lat/lng, ordered by sortOrder
|
||||
const destinations = await prisma.destination.findMany({
|
||||
where: { tripId: params.id, lat: { not: null }, lng: { not: null } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
if (destinations.length < 2) {
|
||||
return NextResponse.json({ segments: [], totalDistance: 0, totalDuration: 0 });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const segments = [];
|
||||
|
||||
for (let i = 0; i < destinations.length - 1; i++) {
|
||||
const from = destinations[i];
|
||||
const to = destinations[i + 1];
|
||||
|
||||
// Check cache
|
||||
let segment = await prisma.routeSegment.findUnique({
|
||||
where: {
|
||||
tripId_fromDestId_toDestId_profile: {
|
||||
tripId: params.id,
|
||||
fromDestId: from.id,
|
||||
toDestId: to.id,
|
||||
profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Recompute if stale or missing
|
||||
if (!segment || now - segment.computedAt.getTime() > STALE_MS) {
|
||||
try {
|
||||
const route = await getRoute(
|
||||
[from.lng!, from.lat!],
|
||||
[to.lng!, to.lat!],
|
||||
profile
|
||||
);
|
||||
segment = await prisma.routeSegment.upsert({
|
||||
where: {
|
||||
tripId_fromDestId_toDestId_profile: {
|
||||
tripId: params.id,
|
||||
fromDestId: from.id,
|
||||
toDestId: to.id,
|
||||
profile,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
distanceMeters: route.distance,
|
||||
durationSeconds: route.duration,
|
||||
geometry: route.geometry,
|
||||
computedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
tripId: params.id,
|
||||
fromDestId: from.id,
|
||||
toDestId: to.id,
|
||||
profile,
|
||||
distanceMeters: route.distance,
|
||||
durationSeconds: route.duration,
|
||||
geometry: route.geometry,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Route ${from.name} → ${to.name} failed:`, err);
|
||||
// Use stale cache if available
|
||||
if (!segment) continue;
|
||||
}
|
||||
}
|
||||
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
const totalDistance = segments.reduce((sum, s) => sum + (s.distanceMeters || 0), 0);
|
||||
const totalDuration = segments.reduce((sum, s) => sum + (s.durationSeconds || 0), 0);
|
||||
|
||||
return NextResponse.json({ segments, totalDistance, totalDuration });
|
||||
} catch (error) {
|
||||
console.error('Get routes error:', error);
|
||||
return NextResponse.json({ error: 'Failed to get routes' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { CanvasEmbed } from '@/components/CanvasEmbed';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
export default function FullScreenCanvas() {
|
||||
const params = useParams();
|
||||
|
|
@ -11,7 +12,7 @@ export default function FullScreenCanvas() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/trips/${params.id}`)
|
||||
fetch(apiUrl(`/api/trips/${params.id}`))
|
||||
.then((res) => res.json())
|
||||
.then((trip) => setCanvasSlug(trip.canvasSlug))
|
||||
.catch(console.error)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { format } from 'date-fns';
|
||||
import { CanvasEmbed } from '@/components/CanvasEmbed';
|
||||
import { CanvasShapeMessage } from '@/lib/canvas-sync';
|
||||
import { AppSwitcher } from '@/components/AppSwitcher';
|
||||
import { AccommodationSearch } from '@/components/trips/AccommodationSearch';
|
||||
import { RouteStats } from '@/components/trips/RouteStats';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
const TripMap = dynamic(() => import('@/components/trips/TripMap').then(m => ({ default: m.TripMap })), { ssr: false });
|
||||
|
||||
interface Trip {
|
||||
id: string;
|
||||
|
|
@ -30,11 +36,22 @@ interface Destination {
|
|||
id: string;
|
||||
name: string;
|
||||
country: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
arrivalDate: string | null;
|
||||
departureDate: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface RouteSegment {
|
||||
id: string;
|
||||
fromDestId: string;
|
||||
toDestId: string;
|
||||
distanceMeters: number | null;
|
||||
durationSeconds: number | null;
|
||||
geometry: string | null;
|
||||
}
|
||||
|
||||
interface ItineraryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -75,10 +92,11 @@ interface PackingItem {
|
|||
quantity: number;
|
||||
}
|
||||
|
||||
type Tab = 'overview' | 'itinerary' | 'destinations' | 'bookings' | 'budget' | 'packing';
|
||||
type Tab = 'overview' | 'itinerary' | 'destinations' | 'bookings' | 'budget' | 'packing' | 'map';
|
||||
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'map', label: 'Map' },
|
||||
{ key: 'itinerary', label: 'Itinerary' },
|
||||
{ key: 'destinations', label: 'Destinations' },
|
||||
{ key: 'bookings', label: 'Bookings' },
|
||||
|
|
@ -119,9 +137,13 @@ export default function TripDashboard() {
|
|||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [showCanvas, setShowCanvas] = useState(false);
|
||||
const [creatingCanvas, setCreatingCanvas] = useState(false);
|
||||
const [segments, setSegments] = useState<RouteSegment[]>([]);
|
||||
const [segmentsLoading, setSegmentsLoading] = useState(false);
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [selectedDestForAccom, setSelectedDestForAccom] = useState<Destination | null>(null);
|
||||
|
||||
const fetchTrip = useCallback(() => {
|
||||
fetch(`/api/trips/${params.id}`)
|
||||
fetch(apiUrl(`/api/trips/${params.id}`))
|
||||
.then((res) => res.json())
|
||||
.then(setTrip)
|
||||
.catch(console.error)
|
||||
|
|
@ -135,7 +157,7 @@ export default function TripDashboard() {
|
|||
const handleShapeUpdate = useCallback(
|
||||
(message: CanvasShapeMessage) => {
|
||||
if (!trip) return;
|
||||
fetch(`/api/trips/${trip.id}/sync`, {
|
||||
fetch(apiUrl(`/api/trips/${trip.id}/sync`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
@ -156,7 +178,7 @@ export default function TripDashboard() {
|
|||
if (!trip) return;
|
||||
setCreatingCanvas(true);
|
||||
try {
|
||||
const res = await fetch(`/api/trips/${trip.id}/canvas`, { method: 'POST' });
|
||||
const res = await fetch(apiUrl(`/api/trips/${trip.id}/canvas`), { method: 'POST' });
|
||||
if (res.ok) {
|
||||
fetchTrip(); // Refresh to get canvasSlug
|
||||
}
|
||||
|
|
@ -167,6 +189,49 @@ export default function TripDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
// Fetch routes when map tab is active
|
||||
const fetchRoutes = useCallback(async () => {
|
||||
if (!trip) return;
|
||||
setSegmentsLoading(true);
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/trips/${trip.id}/routes`));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSegments(data.segments || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch routes:', err);
|
||||
} finally {
|
||||
setSegmentsLoading(false);
|
||||
}
|
||||
}, [trip]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'map' && trip && segments.length === 0 && !segmentsLoading) {
|
||||
fetchRoutes();
|
||||
}
|
||||
}, [activeTab, trip, segments.length, segmentsLoading, fetchRoutes]);
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!trip) return;
|
||||
setIsOptimizing(true);
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/trips/${trip.id}/routes/optimize`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchTrip();
|
||||
setSegments([]); // Clear to trigger refetch
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Optimize failed:', err);
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
|
|
@ -350,9 +415,26 @@ export default function TripDashboard() {
|
|||
)}
|
||||
{dest.notes && <p className="text-sm text-slate-400 mt-2">{dest.notes}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedDestForAccom(dest)}
|
||||
className="text-xs text-teal-400 hover:text-teal-300 self-start mt-1"
|
||||
>
|
||||
Find Accommodation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{selectedDestForAccom && activeTab === 'destinations' && (
|
||||
<AccommodationSearch
|
||||
tripId={trip.id}
|
||||
destination={selectedDestForAccom}
|
||||
budgetCurrency={trip.budgetCurrency}
|
||||
budgetTotal={trip.budgetTotal}
|
||||
destinationCount={trip.destinations.length}
|
||||
onSave={() => { fetchTrip(); setSelectedDestForAccom(null); }}
|
||||
onClose={() => setSelectedDestForAccom(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -432,6 +514,58 @@ export default function TripDashboard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'map' && (
|
||||
<div className="space-y-4">
|
||||
{segmentsLoading && segments.length === 0 && (
|
||||
<div className="text-center py-4 text-slate-400 text-sm">Loading routes...</div>
|
||||
)}
|
||||
<TripMap
|
||||
destinations={trip.destinations}
|
||||
segments={segments}
|
||||
onOptimize={handleOptimize}
|
||||
isOptimizing={isOptimizing}
|
||||
onDestinationClick={(d) => setSelectedDestForAccom(d as Destination)}
|
||||
/>
|
||||
{/* Route stats between destinations */}
|
||||
{segments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{trip.destinations.filter(d => d.lat && d.lng).map((dest, i, arr) => (
|
||||
<div key={dest.id}>
|
||||
<div className="bg-slate-800/50 rounded-lg p-3 border border-slate-700/50 flex items-center gap-3">
|
||||
<div className="w-7 h-7 bg-teal-500/20 rounded-full flex items-center justify-center text-teal-400 text-sm font-bold">
|
||||
{i + 1}
|
||||
</div>
|
||||
<span className="text-sm">{dest.name}</span>
|
||||
{dest.country && <span className="text-xs text-slate-500">({dest.country})</span>}
|
||||
<button
|
||||
onClick={() => setSelectedDestForAccom(dest)}
|
||||
className="ml-auto text-xs text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
Find Accommodation
|
||||
</button>
|
||||
</div>
|
||||
{i < arr.length - 1 && (
|
||||
<RouteStats segments={segments} fromDestId={dest.id} toDestId={arr[i + 1].id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Accommodation search panel */}
|
||||
{selectedDestForAccom && (
|
||||
<AccommodationSearch
|
||||
tripId={trip.id}
|
||||
destination={selectedDestForAccom}
|
||||
budgetCurrency={trip.budgetCurrency}
|
||||
budgetTotal={trip.budgetTotal}
|
||||
destinationCount={trip.destinations.length}
|
||||
onSave={() => { fetchTrip(); setSelectedDestForAccom(null); }}
|
||||
onClose={() => setSelectedDestForAccom(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'packing' && (
|
||||
<div className="space-y-2">
|
||||
{trip.packingItems.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { AppSwitcher } from '@/components/AppSwitcher';
|
|||
import { NLInput } from '@/components/NLInput';
|
||||
import { ParsedTripPreview } from '@/components/ParsedTripPreview';
|
||||
import { ParsedTrip } from '@/lib/types';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
export default function NewTrip() {
|
||||
const router = useRouter();
|
||||
|
|
@ -25,7 +26,7 @@ export default function NewTrip() {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/trips', {
|
||||
const res = await fetch(apiUrl('/api/trips'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parsed: finalParsed, rawInput }),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
|
|||
import Link from 'next/link';
|
||||
import { AppSwitcher } from '@/components/AppSwitcher';
|
||||
import { format } from 'date-fns';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
interface TripSummary {
|
||||
id: string;
|
||||
|
|
@ -38,7 +39,7 @@ export default function TripsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/trips')
|
||||
fetch(apiUrl('/api/trips'))
|
||||
.then((res) => res.json())
|
||||
.then(setTrips)
|
||||
.catch(console.error)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
export interface AppModule {
|
||||
id: string;
|
||||
|
|
@ -122,7 +123,7 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
|||
|
||||
// Fetch current user's username for subdomain links
|
||||
useEffect(() => {
|
||||
fetch('/api/me')
|
||||
fetch(apiUrl('/api/me'))
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated && data.user?.username) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import { ParsedTrip } from '@/lib/types';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
interface NLInputProps {
|
||||
onParsed: (parsed: ParsedTrip, rawInput: string) => void;
|
||||
|
|
@ -18,7 +19,7 @@ export function NLInput({ onParsed }: NLInputProps) {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/trips/parse', {
|
||||
const res = await fetch(apiUrl('/api/trips/parse'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text.trim() }),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
interface SpaceInfo {
|
||||
slug: string;
|
||||
|
|
@ -53,7 +54,7 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
|
|||
setIsAuthenticated(true);
|
||||
} else {
|
||||
// Fallback: check /api/me
|
||||
fetch('/api/me')
|
||||
fetch(apiUrl('/api/me'))
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated) setIsAuthenticated(true);
|
||||
|
|
@ -70,7 +71,7 @@ export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
|
|||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch('/api/spaces', { headers });
|
||||
const res = await fetch(apiUrl('/api/spaces'), { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSpaces(data.spaces || []);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
interface UserInfo {
|
||||
username?: string;
|
||||
|
|
@ -14,7 +15,7 @@ export function UserMenu() {
|
|||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/me')
|
||||
fetch(apiUrl('/api/me'))
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.authenticated && data.user) {
|
||||
|
|
@ -78,7 +79,7 @@ export function UserMenu() {
|
|||
<button
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
fetch(apiUrl('/api/auth/logout'), { method: 'POST' })
|
||||
.catch(() => {})
|
||||
.finally(() => window.location.reload());
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
name: string;
|
||||
country: string | null;
|
||||
arrivalDate: string | null;
|
||||
departureDate: string | null;
|
||||
}
|
||||
|
||||
interface AirbnbListing {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
price: number | null;
|
||||
currency: string;
|
||||
rating: number | null;
|
||||
reviewCount: number | null;
|
||||
roomType: string | null;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
interface AccommodationSearchProps {
|
||||
tripId: string;
|
||||
destination: Destination;
|
||||
budgetCurrency: string;
|
||||
budgetTotal: number | null;
|
||||
destinationCount: number;
|
||||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function AccommodationSearch({
|
||||
tripId,
|
||||
destination,
|
||||
budgetCurrency,
|
||||
budgetTotal,
|
||||
destinationCount,
|
||||
onSave,
|
||||
onClose,
|
||||
}: AccommodationSearchProps) {
|
||||
const [listings, setListings] = useState<AirbnbListing[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchUrl, setSearchUrl] = useState<string | null>(null);
|
||||
const [guests, setGuests] = useState(2);
|
||||
const [maxPrice, setMaxPrice] = useState('');
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const search = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
destId: destination.id,
|
||||
guests: guests.toString(),
|
||||
currency: budgetCurrency,
|
||||
});
|
||||
if (maxPrice) params.set('maxPrice', maxPrice);
|
||||
|
||||
const res = await fetch(apiUrl(`/api/trips/${tripId}/accommodations/search?${params}`));
|
||||
const data = await res.json();
|
||||
setListings(data.listings || []);
|
||||
setSearchUrl(data.searchUrl || null);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tripId, destination.id, guests, maxPrice, budgetCurrency]);
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
}, [search]);
|
||||
|
||||
const handleSave = async (listing: AirbnbListing) => {
|
||||
setSaving(listing.id);
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/trips/${tripId}/accommodations/save`), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
destId: destination.id,
|
||||
listing,
|
||||
checkIn: destination.arrivalDate,
|
||||
checkOut: destination.departureDate,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
onSave?.();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Smart budget estimate
|
||||
const nights =
|
||||
destination.arrivalDate && destination.departureDate
|
||||
? Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(new Date(destination.departureDate).getTime() -
|
||||
new Date(destination.arrivalDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
)
|
||||
)
|
||||
: null;
|
||||
const suggestedMax =
|
||||
budgetTotal && destinationCount
|
||||
? Math.round((budgetTotal / destinationCount) * 0.4 / (nights || 3))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800/50 rounded-xl border border-slate-700/50 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-700/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">Find Accommodation</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{destination.name}{destination.country ? `, ${destination.country}` : ''}
|
||||
{nights && ` · ${nights} night${nights > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white text-lg leading-none">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="p-3 border-b border-slate-700/50 flex gap-3 items-end">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Guests</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={16}
|
||||
value={guests}
|
||||
onChange={(e) => setGuests(parseInt(e.target.value) || 1)}
|
||||
className="w-16 bg-slate-700 border border-slate-600 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">
|
||||
Max Price/night
|
||||
{suggestedMax && (
|
||||
<span className="text-teal-400 ml-1">(~{budgetCurrency} {suggestedMax} from budget)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder={suggestedMax?.toString() || ''}
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className="w-28 bg-slate-700 border border-slate-600 rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={search}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 bg-teal-500 hover:bg-teal-400 disabled:bg-slate-600 text-white text-sm rounded transition-colors"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading && listings.length === 0 && (
|
||||
<div className="p-8 text-center text-slate-400 text-sm">Searching Airbnb...</div>
|
||||
)}
|
||||
{!loading && listings.length === 0 && (
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-slate-400 text-sm">No listings found.</p>
|
||||
{searchUrl && (
|
||||
<a
|
||||
href={searchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-400 hover:text-teal-300 mt-2 inline-block"
|
||||
>
|
||||
Search on Airbnb directly →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{listings.map((listing) => (
|
||||
<div
|
||||
key={listing.id}
|
||||
className="p-3 border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{listing.thumbnail && (
|
||||
<img
|
||||
src={listing.thumbnail}
|
||||
alt=""
|
||||
className="w-20 h-16 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={listing.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium hover:text-teal-400 transition-colors line-clamp-1"
|
||||
>
|
||||
{listing.name || 'Listing'}
|
||||
</a>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-slate-400">
|
||||
{listing.roomType && <span>{listing.roomType}</span>}
|
||||
{listing.rating && (
|
||||
<span>
|
||||
★ {listing.rating.toFixed(1)}
|
||||
{listing.reviewCount && ` (${listing.reviewCount})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
{listing.price ? (
|
||||
<span className="text-sm font-semibold text-teal-400">
|
||||
{listing.currency} {listing.price}
|
||||
<span className="text-xs text-slate-400 font-normal">/night</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">Price unavailable</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSave(listing)}
|
||||
disabled={saving === listing.id}
|
||||
className="px-2 py-0.5 bg-teal-500/20 text-teal-400 hover:bg-teal-500/30 text-xs rounded transition-colors"
|
||||
>
|
||||
{saving === listing.id ? 'Saving...' : 'Save to Trip'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search on Airbnb link */}
|
||||
{searchUrl && listings.length > 0 && (
|
||||
<div className="p-2 text-center border-t border-slate-700/50">
|
||||
<a
|
||||
href={searchUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
View all on Airbnb →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
interface Segment {
|
||||
id: string;
|
||||
fromDestId: string;
|
||||
toDestId: string;
|
||||
distanceMeters: number | null;
|
||||
durationSeconds: number | null;
|
||||
}
|
||||
|
||||
interface RouteStatsProps {
|
||||
segments: Segment[];
|
||||
fromDestId: string;
|
||||
toDestId: string;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.round((seconds % 3600) / 60);
|
||||
if (hours === 0) return `${mins}min`;
|
||||
return `${hours}h ${mins}min`;
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) return `${meters}m`;
|
||||
return `${(meters / 1000).toFixed(0)} km`;
|
||||
}
|
||||
|
||||
export function RouteStats({ segments, fromDestId, toDestId }: RouteStatsProps) {
|
||||
const segment = segments.find(
|
||||
(s) => s.fromDestId === fromDestId && s.toDestId === toDestId
|
||||
);
|
||||
|
||||
if (!segment || (!segment.distanceMeters && !segment.durationSeconds)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 bg-slate-800/30 px-2.5 py-1 rounded-full">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
{segment.durationSeconds && (
|
||||
<span>{formatDuration(segment.durationSeconds)}</span>
|
||||
)}
|
||||
{segment.durationSeconds && segment.distanceMeters && (
|
||||
<span className="text-slate-600">·</span>
|
||||
)}
|
||||
{segment.distanceMeters && (
|
||||
<span>{formatDistance(segment.distanceMeters)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
'use client';
|
||||
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import Map, { Marker, Source, Layer, NavigationControl } from 'react-map-gl/maplibre';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
|
||||
interface Destination {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
id: string;
|
||||
fromDestId: string;
|
||||
toDestId: string;
|
||||
distanceMeters: number | null;
|
||||
durationSeconds: number | null;
|
||||
geometry: string | null;
|
||||
}
|
||||
|
||||
interface TripMapProps {
|
||||
destinations: Destination[];
|
||||
segments: Segment[];
|
||||
onOptimize?: () => void;
|
||||
isOptimizing?: boolean;
|
||||
onDestinationClick?: (dest: Destination) => void;
|
||||
}
|
||||
|
||||
export function TripMap({
|
||||
destinations,
|
||||
segments,
|
||||
onOptimize,
|
||||
isOptimizing,
|
||||
onDestinationClick,
|
||||
}: TripMapProps) {
|
||||
const mapRef = useRef(null);
|
||||
|
||||
const destsWithCoords = useMemo(
|
||||
() => destinations.filter((d) => d.lat != null && d.lng != null),
|
||||
[destinations]
|
||||
);
|
||||
|
||||
// Compute bounds to fit all destinations
|
||||
const bounds = useMemo(() => {
|
||||
if (destsWithCoords.length === 0) return null;
|
||||
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
||||
for (const d of destsWithCoords) {
|
||||
minLng = Math.min(minLng, d.lng!);
|
||||
maxLng = Math.max(maxLng, d.lng!);
|
||||
minLat = Math.min(minLat, d.lat!);
|
||||
maxLat = Math.max(maxLat, d.lat!);
|
||||
}
|
||||
// Add padding
|
||||
const pad = 0.5;
|
||||
return [
|
||||
[minLng - pad, minLat - pad],
|
||||
[maxLng + pad, maxLat + pad],
|
||||
] as [[number, number], [number, number]];
|
||||
}, [destsWithCoords]);
|
||||
|
||||
// Build GeoJSON from route segments
|
||||
const routeGeoJSON = useMemo(() => {
|
||||
const features = segments
|
||||
.filter((s) => s.geometry)
|
||||
.map((s) => {
|
||||
try {
|
||||
const geom = JSON.parse(s.geometry!);
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: s.id,
|
||||
distance: s.distanceMeters,
|
||||
duration: s.durationSeconds,
|
||||
},
|
||||
geometry: geom,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((f): f is NonNullable<typeof f> => f !== null);
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features,
|
||||
};
|
||||
}, [segments]);
|
||||
|
||||
const onMapLoad = useCallback(() => {
|
||||
if (bounds && mapRef.current) {
|
||||
(mapRef.current as { fitBounds: (bounds: [[number, number], [number, number]], opts: object) => void }).fitBounds(bounds, { padding: 60, duration: 0 });
|
||||
}
|
||||
}, [bounds]);
|
||||
|
||||
if (destsWithCoords.length === 0) {
|
||||
return (
|
||||
<div className="bg-slate-800/50 rounded-xl p-8 border border-slate-700/50 text-center">
|
||||
<p className="text-slate-400">No destinations with coordinates yet.</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Add lat/lng to your destinations to see them on the map.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{onOptimize && destsWithCoords.length >= 3 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onOptimize}
|
||||
disabled={isOptimizing}
|
||||
className="px-3 py-1.5 bg-teal-500 hover:bg-teal-400 disabled:bg-slate-600 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isOptimizing ? 'Optimizing...' : 'Optimize Route Order'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl overflow-hidden border border-slate-700/50" style={{ height: 480 }}>
|
||||
<Map
|
||||
ref={mapRef}
|
||||
onLoad={onMapLoad}
|
||||
initialViewState={{
|
||||
longitude: destsWithCoords[0]?.lng || 0,
|
||||
latitude: destsWithCoords[0]?.lat || 0,
|
||||
zoom: 5,
|
||||
}}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle="https://tiles.openfreemap.org/styles/liberty"
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
{/* Route lines */}
|
||||
{routeGeoJSON.features.length > 0 && (
|
||||
<Source id="route" type="geojson" data={routeGeoJSON}>
|
||||
<Layer
|
||||
id="route-line"
|
||||
type="line"
|
||||
paint={{
|
||||
'line-color': '#2dd4bf',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.8,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Destination markers */}
|
||||
{destsWithCoords.map((d, i) => (
|
||||
<Marker
|
||||
key={d.id}
|
||||
longitude={d.lng!}
|
||||
latitude={d.lat!}
|
||||
anchor="center"
|
||||
onClick={() => onDestinationClick?.(d)}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 bg-teal-500 rounded-full flex items-center justify-center text-white text-sm font-bold shadow-lg cursor-pointer hover:bg-teal-400 transition-colors border-2 border-white"
|
||||
title={d.name}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
</Marker>
|
||||
))}
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// Base path for API calls — matches next.config.mjs basePath
|
||||
export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/rtrips';
|
||||
|
||||
/** Prepend basePath to an API path */
|
||||
export function apiUrl(path: string): string {
|
||||
return `${BASE_PATH}${path}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
// Routing client — self-hosted ORS (Europe) + OSRM worldwide fallback
|
||||
//
|
||||
// Priority: ORS self-hosted (Europe) → ORS public API (if key set) → OSRM (worldwide)
|
||||
|
||||
const ORS_BASE_URL = process.env.ORS_BASE_URL || 'https://routing.jeffemmett.com';
|
||||
const ORS_PUBLIC_URL = process.env.ORS_PUBLIC_URL || 'https://api.openrouteservice.org';
|
||||
const ORS_PUBLIC_KEY = process.env.ORS_PUBLIC_KEY || '';
|
||||
const OSRM_URL = 'https://router.project-osrm.org';
|
||||
|
||||
const EUROPE_BBOX = { minLng: -25, maxLng: 45, minLat: 34, maxLat: 72 };
|
||||
|
||||
type Coord = [number, number]; // [lng, lat] — GeoJSON order
|
||||
|
||||
interface RouteResult {
|
||||
distance: number; // meters
|
||||
duration: number; // seconds
|
||||
geometry: string; // GeoJSON LineString as JSON string
|
||||
}
|
||||
|
||||
interface MatrixResult {
|
||||
distances: number[][];
|
||||
durations: number[][];
|
||||
}
|
||||
|
||||
interface OptimizeResult {
|
||||
order: number[]; // optimized waypoint indices
|
||||
distance: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// Profile mapping: ORS → OSRM
|
||||
const OSRM_PROFILE: Record<string, string> = {
|
||||
'driving-car': 'driving',
|
||||
'cycling-regular': 'cycling',
|
||||
'foot-walking': 'foot',
|
||||
};
|
||||
|
||||
function isInEurope(coord: Coord): boolean {
|
||||
const [lng, lat] = coord;
|
||||
return lng >= EUROPE_BBOX.minLng && lng <= EUROPE_BBOX.maxLng &&
|
||||
lat >= EUROPE_BBOX.minLat && lat <= EUROPE_BBOX.maxLat;
|
||||
}
|
||||
|
||||
function allInEurope(coords: Coord[]): boolean {
|
||||
return coords.every(isInEurope);
|
||||
}
|
||||
|
||||
function useORS(coords: Coord[]): boolean {
|
||||
// Use self-hosted ORS for Europe, or ORS public if we have a key
|
||||
return allInEurope(coords) || !!ORS_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
function getOrsUrl(coords: Coord[]): string {
|
||||
return allInEurope(coords) ? ORS_BASE_URL : ORS_PUBLIC_URL;
|
||||
}
|
||||
|
||||
function getOrsHeaders(coords: Coord[]): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (!allInEurope(coords) && ORS_PUBLIC_KEY) {
|
||||
headers['Authorization'] = ORS_PUBLIC_KEY;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ─── ORS implementations ──────────────────────────────────────────
|
||||
|
||||
async function getRouteORS(from: Coord, to: Coord, profile: string): Promise<RouteResult> {
|
||||
const coords = [from, to];
|
||||
const base = getOrsUrl(coords);
|
||||
const res = await fetch(`${base}/ors/v2/directions/${profile}/geojson`, {
|
||||
method: 'POST',
|
||||
headers: getOrsHeaders(coords),
|
||||
body: JSON.stringify({ coordinates: coords }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ORS directions failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
const feature = data.features?.[0];
|
||||
if (!feature) throw new Error('No route found');
|
||||
return {
|
||||
distance: Math.round(feature.properties.summary.distance),
|
||||
duration: Math.round(feature.properties.summary.duration),
|
||||
geometry: JSON.stringify(feature.geometry),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMatrixORS(coords: Coord[], profile: string): Promise<MatrixResult> {
|
||||
const base = getOrsUrl(coords);
|
||||
const res = await fetch(`${base}/ors/v2/matrix/${profile}`, {
|
||||
method: 'POST',
|
||||
headers: getOrsHeaders(coords),
|
||||
body: JSON.stringify({ locations: coords, metrics: ['distance', 'duration'] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ORS matrix failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
return { distances: data.distances, durations: data.durations };
|
||||
}
|
||||
|
||||
async function optimizeOrderORS(coords: Coord[], profile: string): Promise<OptimizeResult> {
|
||||
const base = getOrsUrl(coords);
|
||||
const jobs = coords.map((coord, i) => ({ id: i, location: coord }));
|
||||
const vehicles = [{ id: 0, profile, start: coords[0], end: coords[coords.length - 1] }];
|
||||
const res = await fetch(`${base}/ors/v2/optimization`, {
|
||||
method: 'POST',
|
||||
headers: getOrsHeaders(coords),
|
||||
body: JSON.stringify({ jobs, vehicles }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ORS optimization failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
const route = data.routes?.[0];
|
||||
if (!route) throw new Error('No optimization result');
|
||||
return {
|
||||
order: route.steps
|
||||
.filter((s: { type: string }) => s.type === 'job')
|
||||
.map((s: { id: number }) => s.id),
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── OSRM implementations (worldwide fallback) ────────────────────
|
||||
|
||||
async function getRouteOSRM(from: Coord, to: Coord, profile: string): Promise<RouteResult> {
|
||||
const p = OSRM_PROFILE[profile] || 'driving';
|
||||
const coords = `${from[0]},${from[1]};${to[0]},${to[1]}`;
|
||||
const res = await fetch(
|
||||
`${OSRM_URL}/route/v1/${p}/${coords}?overview=full&geometries=geojson`
|
||||
);
|
||||
if (!res.ok) throw new Error(`OSRM route failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
if (data.code !== 'Ok') throw new Error(`OSRM: ${data.message || data.code}`);
|
||||
const route = data.routes?.[0];
|
||||
if (!route) throw new Error('No route found');
|
||||
return {
|
||||
distance: Math.round(route.distance),
|
||||
duration: Math.round(route.duration),
|
||||
geometry: JSON.stringify(route.geometry),
|
||||
};
|
||||
}
|
||||
|
||||
async function getMatrixOSRM(coords: Coord[], profile: string): Promise<MatrixResult> {
|
||||
const p = OSRM_PROFILE[profile] || 'driving';
|
||||
const coordStr = coords.map((c) => `${c[0]},${c[1]}`).join(';');
|
||||
const res = await fetch(
|
||||
`${OSRM_URL}/table/v1/${p}/${coordStr}?annotations=distance,duration`
|
||||
);
|
||||
if (!res.ok) throw new Error(`OSRM table failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
if (data.code !== 'Ok') throw new Error(`OSRM: ${data.message || data.code}`);
|
||||
return { distances: data.distances, durations: data.durations };
|
||||
}
|
||||
|
||||
async function optimizeOrderOSRM(coords: Coord[], profile: string): Promise<OptimizeResult> {
|
||||
const p = OSRM_PROFILE[profile] || 'driving';
|
||||
const coordStr = coords.map((c) => `${c[0]},${c[1]}`).join(';');
|
||||
const res = await fetch(
|
||||
`${OSRM_URL}/trip/v1/${p}/${coordStr}?roundtrip=false&source=first&destination=last&overview=full&geometries=geojson`
|
||||
);
|
||||
if (!res.ok) throw new Error(`OSRM trip failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
if (data.code !== 'Ok') throw new Error(`OSRM: ${data.message || data.code}`);
|
||||
const trip = data.trips?.[0];
|
||||
if (!trip) throw new Error('No trip result');
|
||||
|
||||
// waypoints[i].waypoint_index = position in optimized trip
|
||||
// Sort by waypoint_index to get original indices in optimized order
|
||||
const sorted = (data.waypoints as { waypoint_index: number }[])
|
||||
.map((w, origIdx) => ({ origIdx, optIdx: w.waypoint_index }))
|
||||
.sort((a, b) => a.optIdx - b.optIdx);
|
||||
|
||||
return {
|
||||
order: sorted.map((s) => s.origIdx),
|
||||
distance: Math.round(trip.distance),
|
||||
duration: Math.round(trip.duration),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public API — tries ORS first, falls back to OSRM ─────────────
|
||||
|
||||
export async function getRoute(
|
||||
from: Coord,
|
||||
to: Coord,
|
||||
profile = 'driving-car'
|
||||
): Promise<RouteResult> {
|
||||
if (useORS([from, to])) {
|
||||
try {
|
||||
return await getRouteORS(from, to, profile);
|
||||
} catch (err) {
|
||||
console.warn('ORS route failed, falling back to OSRM:', err);
|
||||
}
|
||||
}
|
||||
return getRouteOSRM(from, to, profile);
|
||||
}
|
||||
|
||||
export async function getMatrix(
|
||||
coords: Coord[],
|
||||
profile = 'driving-car'
|
||||
): Promise<MatrixResult> {
|
||||
if (useORS(coords)) {
|
||||
try {
|
||||
return await getMatrixORS(coords, profile);
|
||||
} catch (err) {
|
||||
console.warn('ORS matrix failed, falling back to OSRM:', err);
|
||||
}
|
||||
}
|
||||
return getMatrixOSRM(coords, profile);
|
||||
}
|
||||
|
||||
export async function optimizeOrder(
|
||||
coords: Coord[],
|
||||
profile = 'driving-car'
|
||||
): Promise<OptimizeResult> {
|
||||
if (useORS(coords)) {
|
||||
try {
|
||||
return await optimizeOrderORS(coords, profile);
|
||||
} catch (err) {
|
||||
console.warn('ORS optimize failed, falling back to OSRM:', err);
|
||||
}
|
||||
}
|
||||
return optimizeOrderOSRM(coords, profile);
|
||||
}
|
||||
|
||||
export async function getIsochrone(
|
||||
center: Coord,
|
||||
minutes: number,
|
||||
profile = 'driving-car'
|
||||
): Promise<object> {
|
||||
// Isochrones only available via ORS (no OSRM equivalent)
|
||||
const base = getOrsUrl([center]);
|
||||
const res = await fetch(`${base}/ors/v2/isochrones/${profile}`, {
|
||||
method: 'POST',
|
||||
headers: getOrsHeaders([center]),
|
||||
body: JSON.stringify({
|
||||
locations: [center],
|
||||
range: [minutes * 60],
|
||||
range_type: 'time',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`ORS isochrone failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
Loading…
Reference in New Issue