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:
Jeff Emmett 2026-03-24 12:30:39 -07:00
parent 28c1cd1e0d
commit cafbc6c4c6
25 changed files with 2125 additions and 15 deletions

View File

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

View File

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

View File

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

575
package-lock.json generated
View File

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

View File

@ -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": {

View File

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

View File

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

View File

@ -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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 || []);

View File

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

View File

@ -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">
&times;
</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 &rarr;
</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 &rarr;
</a>
</div>
)}
</div>
);
}

View File

@ -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">&middot;</span>
)}
{segment.distanceMeters && (
<span>{formatDistance(segment.distanceMeters)}</span>
)}
</div>
</div>
);
}

View File

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

7
src/lib/api.ts Normal file
View File

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

243
src/lib/ors.ts Normal file
View File

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