diff --git a/.env.example b/.env.example index 12836b7..62c35a0 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 5ba6f04..84b4a95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/next.config.mjs b/next.config.mjs index e25a6a2..d96cef7 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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; diff --git a/package-lock.json b/package-lock.json index 35606ff..50175f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a77850f..ffd5981 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 25c139c..ea53624 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/api/trips/[id]/accommodations/save/route.ts b/src/app/api/trips/[id]/accommodations/save/route.ts new file mode 100644 index 0000000..2bbc470 --- /dev/null +++ b/src/app/api/trips/[id]/accommodations/save/route.ts @@ -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 }); + } +} diff --git a/src/app/api/trips/[id]/accommodations/search/route.ts b/src/app/api/trips/[id]/accommodations/search/route.ts new file mode 100644 index 0000000..5bf8f04 --- /dev/null +++ b/src/app/api/trips/[id]/accommodations/search/route.ts @@ -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(/]*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, 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; + + // Detect listing objects by common Airbnb fields + if (o.listing && typeof o.listing === 'object') { + const l = o.listing as Record; + 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): number | null { + const pricing = obj.pricingQuote || obj.pricing; + if (pricing && typeof pricing === 'object') { + const p = pricing as Record; + 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; + const primary = s.primaryLine; + if (primary && typeof primary === 'object') { + const pr = primary as Record; + 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 | 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; + 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; +} diff --git a/src/app/api/trips/[id]/destinations/[destId]/isochrone/route.ts b/src/app/api/trips/[id]/destinations/[destId]/isochrone/route.ts new file mode 100644 index 0000000..d28fc59 --- /dev/null +++ b/src/app/api/trips/[id]/destinations/[destId]/isochrone/route.ts @@ -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 }); + } +} diff --git a/src/app/api/trips/[id]/destinations/[destId]/nearby/route.ts b/src/app/api/trips/[id]/destinations/[destId]/nearby/route.ts new file mode 100644 index 0000000..ae9198f --- /dev/null +++ b/src/app/api/trips/[id]/destinations/[destId]/nearby/route.ts @@ -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 = { + 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) => { + const lat = (el.lat as number) || ((el.center as Record)?.lat); + const lng = (el.lon as number) || ((el.center as Record)?.lon); + if (!lat || !lng) return null; + + const tags = el.tags as Record | 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)); +} diff --git a/src/app/api/trips/[id]/routes/optimize/route.ts b/src/app/api/trips/[id]/routes/optimize/route.ts new file mode 100644 index 0000000..c6ed262 --- /dev/null +++ b/src/app/api/trips/[id]/routes/optimize/route.ts @@ -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 }); + } +} diff --git a/src/app/api/trips/[id]/routes/route.ts b/src/app/api/trips/[id]/routes/route.ts new file mode 100644 index 0000000..f5dcf95 --- /dev/null +++ b/src/app/api/trips/[id]/routes/route.ts @@ -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 }); + } +} diff --git a/src/app/trips/[id]/canvas/page.tsx b/src/app/trips/[id]/canvas/page.tsx index e24aab4..577a151 100644 --- a/src/app/trips/[id]/canvas/page.tsx +++ b/src/app/trips/[id]/canvas/page.tsx @@ -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) diff --git a/src/app/trips/[id]/page.tsx b/src/app/trips/[id]/page.tsx index b4f89b7..2d96220 100644 --- a/src/app/trips/[id]/page.tsx +++ b/src/app/trips/[id]/page.tsx @@ -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('overview'); const [showCanvas, setShowCanvas] = useState(false); const [creatingCanvas, setCreatingCanvas] = useState(false); + const [segments, setSegments] = useState([]); + const [segmentsLoading, setSegmentsLoading] = useState(false); + const [isOptimizing, setIsOptimizing] = useState(false); + const [selectedDestForAccom, setSelectedDestForAccom] = useState(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 (
@@ -350,9 +415,26 @@ export default function TripDashboard() { )} {dest.notes &&

{dest.notes}

}
+ ))} + {selectedDestForAccom && activeTab === 'destinations' && ( + { fetchTrip(); setSelectedDestForAccom(null); }} + onClose={() => setSelectedDestForAccom(null)} + /> + )} )} @@ -432,6 +514,58 @@ export default function TripDashboard() { )} + {activeTab === 'map' && ( +
+ {segmentsLoading && segments.length === 0 && ( +
Loading routes...
+ )} + setSelectedDestForAccom(d as Destination)} + /> + {/* Route stats between destinations */} + {segments.length > 0 && ( +
+ {trip.destinations.filter(d => d.lat && d.lng).map((dest, i, arr) => ( +
+
+
+ {i + 1} +
+ {dest.name} + {dest.country && ({dest.country})} + +
+ {i < arr.length - 1 && ( + + )} +
+ ))} +
+ )} + {/* Accommodation search panel */} + {selectedDestForAccom && ( + { fetchTrip(); setSelectedDestForAccom(null); }} + onClose={() => setSelectedDestForAccom(null)} + /> + )} +
+ )} + {activeTab === 'packing' && (
{trip.packingItems.length === 0 ? ( diff --git a/src/app/trips/new/page.tsx b/src/app/trips/new/page.tsx index 1887611..c6ad165 100644 --- a/src/app/trips/new/page.tsx +++ b/src/app/trips/new/page.tsx @@ -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 }), diff --git a/src/app/trips/page.tsx b/src/app/trips/page.tsx index 52b74a3..25536d8 100644 --- a/src/app/trips/page.tsx +++ b/src/app/trips/page.tsx @@ -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) diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 24f2dcc..79cabb1 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -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) { diff --git a/src/components/NLInput.tsx b/src/components/NLInput.tsx index 4498a67..1a4bd5e 100644 --- a/src/components/NLInput.tsx +++ b/src/components/NLInput.tsx @@ -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() }), diff --git a/src/components/SpaceSwitcher.tsx b/src/components/SpaceSwitcher.tsx index b23e813..b2dd6f6 100644 --- a/src/components/SpaceSwitcher.tsx +++ b/src/components/SpaceSwitcher.tsx @@ -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 = {}; 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 || []); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index b6e87f0..ac233f4 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -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(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() { + )} +
+ + {/* Filters */} +
+
+ + setGuests(parseInt(e.target.value) || 1)} + className="w-16 bg-slate-700 border border-slate-600 rounded px-2 py-1 text-sm" + /> +
+
+ + setMaxPrice(e.target.value)} + className="w-28 bg-slate-700 border border-slate-600 rounded px-2 py-1 text-sm" + /> +
+ +
+ + {/* Results */} +
+ {loading && listings.length === 0 && ( +
Searching Airbnb...
+ )} + {!loading && listings.length === 0 && ( +
+

No listings found.

+ {searchUrl && ( + + Search on Airbnb directly → + + )} +
+ )} + {listings.map((listing) => ( +
+
+ {listing.thumbnail && ( + + )} +
+ + {listing.name || 'Listing'} + +
+ {listing.roomType && {listing.roomType}} + {listing.rating && ( + + ★ {listing.rating.toFixed(1)} + {listing.reviewCount && ` (${listing.reviewCount})`} + + )} +
+
+ {listing.price ? ( + + {listing.currency} {listing.price} + /night + + ) : ( + Price unavailable + )} + +
+
+
+
+ ))} +
+ + {/* Search on Airbnb link */} + {searchUrl && listings.length > 0 && ( + + )} + + ); +} diff --git a/src/components/trips/RouteStats.tsx b/src/components/trips/RouteStats.tsx new file mode 100644 index 0000000..fa2db01 --- /dev/null +++ b/src/components/trips/RouteStats.tsx @@ -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 ( +
+
+ + + + {segment.durationSeconds && ( + {formatDuration(segment.durationSeconds)} + )} + {segment.durationSeconds && segment.distanceMeters && ( + · + )} + {segment.distanceMeters && ( + {formatDistance(segment.distanceMeters)} + )} +
+
+ ); +} diff --git a/src/components/trips/TripMap.tsx b/src/components/trips/TripMap.tsx new file mode 100644 index 0000000..70a19f2 --- /dev/null +++ b/src/components/trips/TripMap.tsx @@ -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 => 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 ( +
+

No destinations with coordinates yet.

+

Add lat/lng to your destinations to see them on the map.

+
+ ); + } + + return ( +
+ {onOptimize && destsWithCoords.length >= 3 && ( +
+ +
+ )} +
+ + + + {/* Route lines */} + {routeGeoJSON.features.length > 0 && ( + + + + )} + + {/* Destination markers */} + {destsWithCoords.map((d, i) => ( + onDestinationClick?.(d)} + > +
+ {i + 1} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..c68c3eb --- /dev/null +++ b/src/lib/api.ts @@ -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}`; +} diff --git a/src/lib/ors.ts b/src/lib/ors.ts new file mode 100644 index 0000000..aabd180 --- /dev/null +++ b/src/lib/ors.ts @@ -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 = { + '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 { + const headers: Record = { '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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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(); +}