mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
Compare commits
71 Commits
e65acb3de7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0631e34a79 | |||
| 8a013f6fa9 | |||
| 7c3440f139 | |||
| 4ceea09e31 | |||
| 03cdb4d276 | |||
| f0877a2e7d | |||
| aa91f009ad | |||
| 2277f28a57 | |||
| 1ec2d62b1c | |||
| 649735726f | |||
| 4e91fbca48 | |||
| 4cb9b18cc6 | |||
| f3b54166fb | |||
| 8c63235cd2 | |||
| 3554fde8d6 | |||
| eb0ab4001d | |||
| 497d8e854f | |||
| e6fe14cac2 | |||
| 2a8caf6e7d | |||
| 005e0c109d | |||
| e54ea2f17d | |||
| 544a76d2da | |||
| a5ba246cb8 | |||
| 0b2780ead2 | |||
| 91fcaa50f6 | |||
| 9669642c62 | |||
| 7531badbe8 | |||
| 424018fc66 | |||
| 9d8af4b357 | |||
| 5b3f77f11d | |||
| e04cf85bef | |||
| 3d65bb0c12 | |||
| 94dca8cad7 | |||
| b1145e7e0a | |||
| 382ec37142 | |||
| 92e3ebb4d5 | |||
| 49fb2fded2 | |||
| 4cd4c9c8d8 | |||
| 6cc8908f87 | |||
| 68f48bc070 | |||
| 76d8abb44d | |||
| 91c350c946 | |||
| 1e4a9a95c2 | |||
| fe54f45d62 | |||
| b36c9931b3 | |||
| c1fe1d2d6a | |||
| ebbbf91d60 | |||
| 328d1c9468 | |||
| 48ebdff2d5 | |||
| 457a42b229 | |||
| 7df5956920 | |||
| 0d50d5d7c3 | |||
| 4a3aa478c6 | |||
| abee2fc088 | |||
| e40465ba1f | |||
| 8dab26fe7b | |||
| 7459067b2e | |||
| a2c552f04d | |||
| 27762458e6 | |||
| adbe15abc4 | |||
| 982b99f0f6 | |||
| 6a797a39ae | |||
| d2cd317070 | |||
| 6ab6d79494 | |||
| d35972db39 | |||
| 438d4fc400 | |||
| d152f9d02b | |||
| f6af1d67a2 | |||
| ad893eb1cc | |||
| b25eb18ea4 | |||
| 8410d7c4a5 |
@@ -32,6 +32,7 @@ server/tests/
|
|||||||
server/vitest.config.ts
|
server/vitest.config.ts
|
||||||
server/reset-admin.js
|
server/reset-admin.js
|
||||||
**/*.test.ts
|
**/*.test.ts
|
||||||
|
**/*.spec.ts
|
||||||
wiki/
|
wiki/
|
||||||
scripts/
|
scripts/
|
||||||
charts/
|
charts/
|
||||||
|
|||||||
@@ -34,4 +34,5 @@ jobs:
|
|||||||
command: cves
|
command: cves
|
||||||
image: trek:scan
|
image: trek:scan
|
||||||
only-severities: critical,high
|
only-severities: critical,high
|
||||||
|
only-fixed: true
|
||||||
exit-code: true
|
exit-code: true
|
||||||
|
|||||||
+19
-2
@@ -1,3 +1,10 @@
|
|||||||
|
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||||
|
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||||
|
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||||
|
# The binary and its runtime behaviour are identical to the apt package.
|
||||||
|
FROM golang:1.25-alpine AS gosu-build
|
||||||
|
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||||
|
|
||||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:24-alpine AS shared-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -44,7 +51,7 @@ COPY server/package.json ./server/
|
|||||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||||
npm ci --workspace=server --omit=dev && \
|
npm ci --workspace=server --omit=dev && \
|
||||||
ARCH=$(dpkg --print-architecture) && \
|
ARCH=$(dpkg --print-architecture) && \
|
||||||
if [ "$ARCH" = "amd64" ]; then \
|
if [ "$ARCH" = "amd64" ]; then \
|
||||||
@@ -60,6 +67,9 @@ RUN apt-get update && \
|
|||||||
apt-get autoremove -y && \
|
apt-get autoremove -y && \
|
||||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
|
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||||
|
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||||
|
|
||||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||||
# Prevent Qt from probing for a display in headless containers.
|
# Prevent Qt from probing for a display in headless containers.
|
||||||
ENV QT_QPA_PLATFORM=offscreen
|
ENV QT_QPA_PLATFORM=offscreen
|
||||||
@@ -75,6 +85,10 @@ COPY --from=server-builder /app/server/dist ./server/dist
|
|||||||
COPY --from=server-builder /app/server/assets ./server/assets
|
COPY --from=server-builder /app/server/assets ./server/assets
|
||||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
COPY server/tsconfig.json ./server/
|
COPY server/tsconfig.json ./server/
|
||||||
|
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||||
|
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||||
|
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||||
|
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
COPY --from=client-builder /app/client/dist ./server/public
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
@@ -95,5 +109,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||||
|
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||||
|
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||||
|
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||||
@@ -90,7 +91,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### 🧳 Travel management
|
#### 🧳 Travel management
|
||||||
|
|
||||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||||
@@ -108,6 +109,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
- **Invite links** — one-time or reusable links with expiry
|
- **Invite links** — one-time or reusable links with expiry
|
||||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
- **2FA** — TOTP + backup codes
|
- **2FA** — TOTP + backup codes
|
||||||
|
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -128,13 +130,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||||
- **Documents** — file attachments on trips, places, and reservations
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -156,8 +158,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
|
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -191,9 +194,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -263,7 +266,7 @@ Then:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -311,6 +314,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
|||||||
|
|
||||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||||
|
|
||||||
<h3>Rotating the Encryption Key</h3>
|
<h3>Rotating the Encryption Key</h3>
|
||||||
|
|
||||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||||
@@ -397,12 +403,14 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||||
|
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||||
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||||
| **OIDC / SSO** | | |
|
| **OIDC / SSO** | | |
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<CommunityApplications>
|
||||||
|
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||||
|
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||||
|
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||||
|
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||||
|
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||||
|
<DonateText>Support TREK development</DonateText>
|
||||||
|
</CommunityApplications>
|
||||||
+1
-1
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
- PVCs require a default StorageClass or specify one as needed.
|
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.22
|
version: 3.1.3
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.22"
|
appVersion: "3.1.3"
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ data:
|
|||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.SESSION_DURATION }}
|
||||||
|
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
||||||
|
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.TRUST_PROXY }}
|
{{- if .Values.env.TRUST_PROXY }}
|
||||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -64,3 +70,9 @@ data:
|
|||||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.OVERPASS_URL }}
|
||||||
|
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
|
||||||
|
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -5,9 +5,16 @@ metadata:
|
|||||||
name: {{ include "trek.fullname" . }}-data
|
name: {{ include "trek.fullname" . }}-data
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.persistence.data.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
|
{{- with .Values.persistence.data.storageClassName }}
|
||||||
|
storageClassName: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.data.size }}
|
storage: {{ .Values.persistence.data.size }}
|
||||||
@@ -18,9 +25,16 @@ metadata:
|
|||||||
name: {{ include "trek.fullname" . }}-uploads
|
name: {{ include "trek.fullname" . }}-uploads
|
||||||
labels:
|
labels:
|
||||||
app: {{ include "trek.name" . }}
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.persistence.uploads.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- ReadWriteOnce
|
- ReadWriteOnce
|
||||||
|
{{- with .Values.persistence.uploads.storageClassName }}
|
||||||
|
storageClassName: {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.persistence.uploads.size }}
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ env:
|
|||||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
|
# SESSION_DURATION: "24h"
|
||||||
|
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
||||||
|
# SESSION_DURATION_REMEMBER: "30d"
|
||||||
|
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||||
# ALLOW_INTERNAL_NETWORK: "false"
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
@@ -63,6 +67,12 @@ env:
|
|||||||
# Max MCP API requests per user per minute. Defaults to 300.
|
# Max MCP API requests per user per minute. Defaults to 300.
|
||||||
# MCP_MAX_SESSION_PER_USER: "20"
|
# MCP_MAX_SESSION_PER_USER: "20"
|
||||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||||
|
# OVERPASS_URL: ""
|
||||||
|
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||||
|
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||||
|
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
|
||||||
|
# OVERPASS_TIMEOUT_MS: "12000"
|
||||||
|
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
|
||||||
|
|
||||||
|
|
||||||
# Secret environment variables stored in a Kubernetes Secret.
|
# Secret environment variables stored in a Kubernetes Secret.
|
||||||
@@ -94,8 +104,13 @@ persistence:
|
|||||||
enabled: true
|
enabled: true
|
||||||
data:
|
data:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
|
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||||
|
storageClassName: ""
|
||||||
|
annotations: {}
|
||||||
uploads:
|
uploads:
|
||||||
size: 1Gi
|
size: 1Gi
|
||||||
|
storageClassName: ""
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
|||||||
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.22",
|
"version": "3.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
@@ -58,11 +59,12 @@
|
|||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-flat-gitignore": "^2.3.0",
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
@@ -80,8 +82,8 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "8.1.0",
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||||
type DayCreateRequest, type DayUpdateRequest,
|
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||||
@@ -100,6 +100,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
|||||||
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||||
|
sv: 'För många försök. Prova igen senare.',
|
||||||
}
|
}
|
||||||
|
|
||||||
function translateRateLimit(): string {
|
function translateRateLimit(): string {
|
||||||
@@ -341,6 +342,7 @@ export const daysApi = {
|
|||||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||||
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placesApi = {
|
export const placesApi = {
|
||||||
@@ -365,10 +367,10 @@ export const placesApi = {
|
|||||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string) =>
|
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||||
importNaverList: (tripId: number | string, url: string) =>
|
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -486,6 +488,20 @@ export const addonsApi = {
|
|||||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const airtrailApi = {
|
||||||
|
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||||
|
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||||
|
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||||
|
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||||
|
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||||
|
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
||||||
|
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
||||||
|
// flights + import are added with the trip-planner import (P2)
|
||||||
|
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
||||||
|
import: (tripId: number, flightIds: string[]) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const journeyApi = {
|
export const journeyApi = {
|
||||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||||
@@ -557,6 +573,11 @@ export const mapsApi = {
|
|||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||||
|
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||||
|
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||||
|
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||||
|
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||||
|
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const airportsApi = {
|
export const airportsApi = {
|
||||||
@@ -575,6 +596,7 @@ export const budgetApi = {
|
|||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||||
|
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
|
|||||||
return mySocketId
|
return mySocketId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
||||||
|
* trip's store after the network comes back via the `online` event. */
|
||||||
|
export function getActiveTrips(): string[] {
|
||||||
|
return Array.from(activeTrips)
|
||||||
|
}
|
||||||
|
|
||||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ import { useToast } from '../shared/Toast'
|
|||||||
import Section from '../Settings/Section'
|
import Section from '../Settings/Section'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { MapView } from '../Map/MapView'
|
import { MapView } from '../Map/MapView'
|
||||||
import type { Place } from '../../types'
|
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||||
|
import type { DistanceUnit, Place } from '../../types'
|
||||||
|
import {
|
||||||
|
MAPBOX_DEFAULT_STYLE,
|
||||||
|
defaultStyleForProvider,
|
||||||
|
getStylePresets,
|
||||||
|
isOpenFreeMapStyle,
|
||||||
|
normalizeStyleForProvider,
|
||||||
|
styleSettingKey,
|
||||||
|
type GlMapProvider,
|
||||||
|
} from '../Map/glProviders'
|
||||||
|
|
||||||
const MAP_PRESETS = [
|
const MAP_PRESETS = [
|
||||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||||
@@ -18,10 +28,30 @@ const MAP_PRESETS = [
|
|||||||
|
|
||||||
type Defaults = {
|
type Defaults = {
|
||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
|
distance_unit?: DistanceUnit
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
|
default_currency?: string
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_tile_url?: string
|
map_tile_url?: string
|
||||||
|
map_provider?: string
|
||||||
|
mapbox_access_token?: string
|
||||||
|
mapbox_style?: string
|
||||||
|
maplibre_style?: string
|
||||||
|
mapbox_3d_enabled?: boolean
|
||||||
|
mapbox_quality_mode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapProvider = 'leaflet' | GlMapProvider
|
||||||
|
|
||||||
|
function normalizeProvider(value: unknown): MapProvider {
|
||||||
|
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||||
|
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||||
|
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||||
|
return normalizeStyleForProvider(provider, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OptionRow({
|
function OptionRow({
|
||||||
@@ -77,11 +107,16 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
const [defaults, setDefaults] = useState<Defaults>({})
|
const [defaults, setDefaults] = useState<Defaults>({})
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||||
|
const [mapboxToken, setMapboxToken] = useState('')
|
||||||
|
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||||
|
const provider = normalizeProvider(data.map_provider)
|
||||||
setDefaults(data)
|
setDefaults(data)
|
||||||
setMapTileUrl(data.map_tile_url || '')
|
setMapTileUrl(data.map_tile_url || '')
|
||||||
|
setMapboxToken(data.mapbox_access_token || '')
|
||||||
|
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => setLoaded(true))
|
}).catch(() => setLoaded(true))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -101,6 +136,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||||
setDefaults(updated)
|
setDefaults(updated)
|
||||||
if (key === 'map_tile_url') setMapTileUrl('')
|
if (key === 'map_tile_url') setMapTileUrl('')
|
||||||
|
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||||
|
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||||
|
const provider = normalizeProvider(defaults.map_provider)
|
||||||
|
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||||
|
}
|
||||||
toast.success(t('admin.defaultSettings.reset'))
|
toast.success(t('admin.defaultSettings.reset'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||||
@@ -150,6 +190,20 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const darkMode = defaults.dark_mode
|
const darkMode = defaults.dark_mode
|
||||||
|
const mapProvider = normalizeProvider(defaults.map_provider)
|
||||||
|
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
|
||||||
|
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
||||||
|
const saveMapProvider = (nextProvider: MapProvider) => {
|
||||||
|
const patch: Partial<Defaults> = { map_provider: nextProvider }
|
||||||
|
if (nextProvider !== 'leaflet') {
|
||||||
|
// Load + save the new provider's own style slot so the other provider's style is kept.
|
||||||
|
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
|
||||||
|
const nextStyle = styleForProvider(nextProvider, slot)
|
||||||
|
setMapboxStyle(nextStyle)
|
||||||
|
patch[styleSettingKey(nextProvider)] = nextStyle
|
||||||
|
}
|
||||||
|
save(patch)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||||
@@ -190,6 +244,22 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Distance */}
|
||||||
|
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: 'metric', label: 'km Metric' },
|
||||||
|
{ value: 'imperial', label: 'mi Imperial' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={opt.value}
|
||||||
|
active={defaults.distance_unit === opt.value}
|
||||||
|
onClick={() => save({ distance_unit: opt.value })}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
{/* Time Format */}
|
{/* Time Format */}
|
||||||
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||||
{([
|
{([
|
||||||
@@ -206,6 +276,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
|
{/* Default Currency */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||||
|
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={defaults.default_currency || ''}
|
||||||
|
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
|
||||||
|
placeholder={t('settings.currency')}
|
||||||
|
searchable
|
||||||
|
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||||
|
size="sm"
|
||||||
|
style={{ maxWidth: 240 }}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
{([
|
{([
|
||||||
@@ -267,6 +354,105 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||||
|
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||||
|
<OptionRow
|
||||||
|
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||||
|
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||||
|
>
|
||||||
|
{([
|
||||||
|
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||||
|
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||||
|
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton
|
||||||
|
key={opt.value}
|
||||||
|
active={mapProvider === opt.value}
|
||||||
|
onClick={() => saveMapProvider(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
{mapProvider !== 'leaflet' && (
|
||||||
|
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
|
{mapProvider === 'mapbox-gl' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||||
|
{t('admin.defaultSettings.mapboxToken')}
|
||||||
|
<ResetButton field="mapbox_access_token" />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mapboxToken}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||||
|
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||||
|
placeholder="pk.eyJ…"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||||
|
{t('admin.defaultSettings.mapboxStyle')}
|
||||||
|
<ResetButton field={styleKey} />
|
||||||
|
</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={mapboxStyle}
|
||||||
|
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
|
||||||
|
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||||
|
options={glStylePresets.map(p => ({ value: p.url, label: p.name }))}
|
||||||
|
size="sm"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mapboxStyle}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
||||||
|
setMapboxStyle(nextStyle)
|
||||||
|
save({ [styleKey]: nextStyle })
|
||||||
|
}}
|
||||||
|
placeholder={defaultStyleForProvider(mapProvider)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mapProvider === 'mapbox-gl' && (
|
||||||
|
<>
|
||||||
|
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
|
||||||
|
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||||
|
{([
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||||
|
{opt.label}
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
</OptionRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { server } from '../../../tests/helpers/msw/server'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||||
|
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||||
|
import CostsPanel from './CostsPanel'
|
||||||
|
|
||||||
|
const tripMembers = [
|
||||||
|
{ id: 1, username: 'alice', avatar_url: null },
|
||||||
|
{ id: 2, username: 'bob', avatar_url: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores()
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||||
|
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CostsPanel — settlements in the ledger', () => {
|
||||||
|
it('renders a settle-up payment as a ledger row with an undo action', async () => {
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
balances: [],
|
||||||
|
flows: [],
|
||||||
|
settlements: [
|
||||||
|
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
// The expense and the settlement (payment) both appear in the unified ledger.
|
||||||
|
await screen.findByText('Dinner')
|
||||||
|
await screen.findByText('Payment')
|
||||||
|
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||||
|
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a manual payment via the Add payment button', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||||
|
// The footer submit is the second "Add payment" control once the modal is open.
|
||||||
|
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||||
|
const submit = addButtons[addButtons.length - 1]
|
||||||
|
await user.click(submit)
|
||||||
|
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides payment rows while a text search is active', async () => {
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
balances: [],
|
||||||
|
flows: [],
|
||||||
|
settlements: [
|
||||||
|
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await screen.findByText('Payment')
|
||||||
|
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
|
||||||
|
// Payment rows have no name, so a search hides them while the matching expense stays.
|
||||||
|
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||||
|
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||||
|
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||||
|
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||||
|
expect(nums()[2].value).toBe('50')
|
||||||
|
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
||||||
|
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
||||||
|
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
||||||
|
|
||||||
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
|
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||||
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
|
expect(posted!.total_price).toBe(100)
|
||||||
|
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||||
|
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
|
||||||
|
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
|
||||||
|
|
||||||
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
|
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||||
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
|
expect(posted!.total_price).toBe(39.99)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks an expense with no payer as Unfinished', async () => {
|
||||||
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
)
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
await screen.findByText('Hotel')
|
||||||
|
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||||
|
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
|
||||||
|
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
|
||||||
|
|
||||||
|
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
|
||||||
|
// The participant toggles are buttons; the same names also appear as plain text in
|
||||||
|
// the Balances sidebar, so target the buttons specifically.
|
||||||
|
await user.click(screen.getByRole('button', { name: /alice/i }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||||
|
|
||||||
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
|
const submit = addBtns[addBtns.length - 1] // footer submit
|
||||||
|
expect(submit).not.toBeDisabled()
|
||||||
|
await user.click(submit)
|
||||||
|
|
||||||
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
|
expect(posted!.total_price).toBe(120)
|
||||||
|
expect(posted!.member_ids).toEqual([])
|
||||||
|
expect(posted!.payers).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -39,6 +39,12 @@ interface SettlementData {
|
|||||||
settlements: Settlement[]
|
settlements: Settlement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||||
|
// carrying the date used to group it by day.
|
||||||
|
type LedgerEntry =
|
||||||
|
| { kind: 'expense'; date: string; e: BudgetItem }
|
||||||
|
| { kind: 'payment'; date: string; s: Settlement }
|
||||||
|
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||||
|
|
||||||
@@ -62,9 +68,10 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [histOpen, setHistOpen] = useState(false)
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||||
|
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||||
|
const [addingPayment, setAddingPayment] = useState(false)
|
||||||
|
|
||||||
const people = tripMembers
|
const people = tripMembers
|
||||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||||
@@ -122,21 +129,37 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
return list
|
return list
|
||||||
}, [budgetItems, filter, search, me])
|
}, [budgetItems, filter, search, me])
|
||||||
|
|
||||||
|
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||||
|
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||||
|
// under "mine", only show transfers I'm part of.
|
||||||
|
const filteredSettlements = useMemo(() => {
|
||||||
|
if (search.trim()) return []
|
||||||
|
if (filter === 'owed') return []
|
||||||
|
let list = settlement?.settlements || []
|
||||||
|
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||||
|
return list
|
||||||
|
}, [settlement, filter, search, me])
|
||||||
|
|
||||||
const dayGroups = useMemo(() => {
|
const dayGroups = useMemo(() => {
|
||||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
const entries: LedgerEntry[] = [
|
||||||
const labelOf = (e: BudgetItem) => {
|
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||||
if (!e.expense_date) return t('costs.noDate')
|
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
]
|
||||||
|
const labelOf = (date: string) => {
|
||||||
|
if (!date) return t('costs.noDate')
|
||||||
|
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
|
||||||
}
|
}
|
||||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
// Newest day first; within a day, expenses before payments (insertion order).
|
||||||
for (const e of sorted) {
|
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||||||
const day = labelOf(e)
|
const groups: { day: string; entries: LedgerEntry[] }[] = []
|
||||||
|
for (const en of sorted) {
|
||||||
|
const day = labelOf(en.date)
|
||||||
let g = groups.find(x => x.day === day)
|
let g = groups.find(x => x.day === day)
|
||||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
if (!g) { g = { day, entries: [] }; groups.push(g) }
|
||||||
g.items.push(e)
|
g.entries.push(en)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}, [filtered, locale, t])
|
}, [filtered, filteredSettlements, locale, t])
|
||||||
|
|
||||||
// ── settle actions ──────────────────────────────────────────────────────
|
// ── settle actions ──────────────────────────────────────────────────────
|
||||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||||
@@ -280,14 +303,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||||
</div>
|
</div>
|
||||||
) : dayGroups.map(g => {
|
) : dayGroups.map(g => {
|
||||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||||
return (
|
return (
|
||||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
{g.entries.map(en => en.kind === 'expense'
|
||||||
|
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||||
|
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -300,11 +325,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
{canEdit && (
|
||||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
<button onClick={() => setAddingPayment(true)}
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
className="text-content-muted bg-surface-secondary border border-edge"
|
||||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
</button>
|
<Plus size={13} /> {t('costs.addPayment')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SettleFlows />
|
<SettleFlows />
|
||||||
</div>
|
</div>
|
||||||
@@ -330,9 +357,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
{(editingSettlement || addingPayment) && (
|
||||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||||
</Modal>
|
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||||
|
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.costs-root {
|
.costs-root {
|
||||||
@@ -438,7 +467,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
{canEdit && (
|
||||||
|
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<SettleFlows />
|
<SettleFlows />
|
||||||
</div>
|
</div>
|
||||||
@@ -458,11 +489,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
{dayGroups.length === 0
|
{dayGroups.length === 0
|
||||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||||
: dayGroups.map(g => {
|
: dayGroups.map(g => {
|
||||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||||
return (
|
return (
|
||||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
||||||
|
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||||
|
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -490,11 +523,27 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const cur = curOf(e)
|
const cur = curOf(e)
|
||||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||||
|
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||||
|
// total but stays out of settlements until who-paid is filled in.
|
||||||
|
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
|
||||||
|
<Icon size={21} />
|
||||||
|
{isMobile && isUnfinished && (
|
||||||
|
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||||
|
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||||
|
{isUnfinished && !isMobile && (
|
||||||
|
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||||
|
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
||||||
|
{t('costs.unfinished')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{payers.length > 0 && (
|
{payers.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||||
{payers.map(p => (
|
{payers.map(p => (
|
||||||
@@ -514,7 +563,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||||
</div>
|
</div>
|
||||||
@@ -531,6 +580,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A settle-up payment as a ledger row — visually distinct from an expense, with
|
||||||
|
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
|
||||||
|
function SettlementRow({ s }: { s: Settlement }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||||
|
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||||
|
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||||
|
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||||
|
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||||
@@ -562,14 +637,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
|
|
||||||
function CategoryBreakdown() {
|
function CategoryBreakdown() {
|
||||||
const tot: Record<string, number> = {}
|
const tot: Record<string, number> = {}
|
||||||
let grand = 0
|
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
||||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
|
|
||||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
|
||||||
|
// Bars are scaled relative to the most expensive category (the top row fills the
|
||||||
|
// bar), not to the trip grand total — makes the relative ranking readable.
|
||||||
|
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{rows.map(c => {
|
{rows.map(c => {
|
||||||
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
|
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
||||||
return (
|
return (
|
||||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||||
@@ -633,37 +710,75 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||||
|
// X" works the same whether or not there's an outstanding expense behind it.
|
||||||
|
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||||
|
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
const toast = useToast()
|
||||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
||||||
|
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||||
|
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||||
|
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const amt = parseFloat(amount) || 0
|
||||||
|
const valid = amt > 0 && fromId !== toId
|
||||||
|
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!valid) return
|
||||||
|
setSaving(true)
|
||||||
|
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||||
|
try {
|
||||||
|
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||||
|
else await budgetApi.createSettlement(tripId, data)
|
||||||
|
onSaved()
|
||||||
|
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||||
|
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
footer={
|
||||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||||
|
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.from')}</label>
|
||||||
|
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.to')}</label>
|
||||||
|
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.amount')}</label>
|
||||||
|
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
</Modal>
|
||||||
{settlements.map(s => (
|
|
||||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
|
||||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
|
||||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
export interface ExpensePrefill {
|
||||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
name?: string
|
||||||
|
category?: string
|
||||||
|
amount?: number
|
||||||
|
reservationId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
||||||
|
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -671,34 +786,99 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
const { convert } = useExchangeRates(base)
|
const { convert } = useExchangeRates(base)
|
||||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||||
|
|
||||||
const [name, setName] = useState(editing?.name || '')
|
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
// One participant list: a person is "in" the split and may have paid an amount.
|
||||||
|
// Entering the total auto-distributes it equally across the non-pinned participants;
|
||||||
|
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
||||||
|
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
||||||
|
// toward the trip total only, never settlements, until who-paid is filled in).
|
||||||
|
const [total, setTotal] = useState<string>(() => {
|
||||||
|
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||||
|
if (prefill?.amount != null) return String(prefill.amount)
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||||
|
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||||
|
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
||||||
const m: Record<number, string> = {}
|
const m: Record<number, string> = {}
|
||||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
const [split, setSplit] = useState<Set<number>>(() =>
|
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
||||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
// payer amounts load as pinned so opening an expense never reshuffles them.
|
||||||
|
const [dirty, setDirty] = useState<Set<number>>(() =>
|
||||||
|
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
const totalNum = parseFloat(total) || 0
|
||||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
||||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
const paidEntered = paidSum > 0
|
||||||
|
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||||
|
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||||
|
// No participants = a recorded total with nobody to split with (e.g. a booking
|
||||||
|
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
|
||||||
|
// people only adds the who-owes-whom split on top.
|
||||||
|
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
|
||||||
|
|
||||||
|
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||||
|
const splitCents = (amount: number, n: number): number[] => {
|
||||||
|
if (n <= 0) return []
|
||||||
|
const cents = Math.max(0, Math.round(amount * 100))
|
||||||
|
const base = Math.floor(cents / n), rem = cents - base * n
|
||||||
|
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||||
|
}
|
||||||
|
// Recompute the non-pinned participants so every paid amount sums to the total.
|
||||||
|
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
||||||
|
const ids = [...parts]
|
||||||
|
const free = ids.filter(id => !dirtySet.has(id))
|
||||||
|
if (free.length === 0) return paidMap
|
||||||
|
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
||||||
|
const shares = splitCents(totalVal - pinnedSum, free.length)
|
||||||
|
const next = { ...paidMap }
|
||||||
|
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTotalChange = (v: string) => {
|
||||||
|
v = v.replace(',', '.')
|
||||||
|
setTotal(v)
|
||||||
|
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||||
|
}
|
||||||
|
const onPaidChange = (id: number, v: string) => {
|
||||||
|
v = v.replace(',', '.')
|
||||||
|
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||||
|
setDirty(nextDirty)
|
||||||
|
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||||
|
}
|
||||||
|
const toggleParticipant = (id: number) => {
|
||||||
|
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
||||||
|
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
||||||
|
else nextParts.add(id)
|
||||||
|
setParticipants(nextParts); setDirty(nextDirty)
|
||||||
|
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
||||||
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
const payerList = [...participants]
|
||||||
|
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
||||||
|
.filter(p => p.amount > 0)
|
||||||
const data = {
|
const data = {
|
||||||
name: name.trim(), category: cat,
|
name: name.trim(), category: cat,
|
||||||
// Store the actual currency the amounts were entered in; conversion to the
|
// Store the actual currency the amounts were entered in; conversion to the
|
||||||
// viewer's display currency happens live (real rates), no manual rate.
|
// viewer's display currency happens live (real rates), no manual rate.
|
||||||
currency,
|
currency,
|
||||||
payers: payerList, member_ids: [...split],
|
payers: payerList, member_ids: [...participants],
|
||||||
expense_date: day || null,
|
expense_date: day || null,
|
||||||
|
// Always record the entered total: the server keeps it as-is for an unfinished
|
||||||
|
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
||||||
|
total_price: totalNum,
|
||||||
|
// Link a freshly-created expense to its booking (create-from-booking flow).
|
||||||
|
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||||
@@ -728,7 +908,9 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
||||||
|
onChange={e => onTotalChange(e.target.value)}
|
||||||
|
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
@@ -744,11 +926,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currency !== base && payersTotal > 0 && (
|
{currency !== base && totalNum > 0 && (
|
||||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||||
<span className="text-content-faint">≈</span>
|
<span className="text-content-faint">≈</span>
|
||||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -773,39 +955,37 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
|||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{people.map(p => (
|
{people.map((p, idx) => {
|
||||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
const on = participants.has(p.id)
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
|
||||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
|
||||||
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
|
||||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
|
||||||
{people.map(p => {
|
|
||||||
const on = split.has(p.id)
|
|
||||||
return (
|
return (
|
||||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
{p.avatar_url
|
||||||
{p.avatar_url
|
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
{p.id === me ? t('costs.you') : p.username}
|
</button>
|
||||||
</button>
|
{on ? (
|
||||||
|
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||||
|
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
||||||
|
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||||
|
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
<span className="text-content-faint">
|
||||||
|
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
|
</span>
|
||||||
|
{paidEntered
|
||||||
|
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||||
|
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,8 +32,32 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
|||||||
|
|
||||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||||
|
|
||||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
/**
|
||||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||||
return COST_CAT_META.other
|
* which never matched the lowercase keys and fell through to `other`.
|
||||||
|
*/
|
||||||
|
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||||
|
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||||
|
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||||
|
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
||||||
|
transport: 'transport', transportation: 'transport',
|
||||||
|
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||||
|
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||||
|
grocery: 'groceries', groceries: 'groceries',
|
||||||
|
activity: 'activities', activities: 'activities',
|
||||||
|
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||||
|
shop: 'shopping', shopping: 'shopping',
|
||||||
|
fee: 'fees', fees: 'fees',
|
||||||
|
health: 'health', medical: 'health',
|
||||||
|
tip: 'tips', tips: 'tips',
|
||||||
|
other: 'other', misc: 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||||
|
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||||
|
if (!cat) return COST_CAT_META.other
|
||||||
|
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||||
|
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||||
|
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
import { forwardRef, lazy, Suspense, useImperativeHandle, useRef } from 'react'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||||
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
import type { JourneyMapGLHandle } from './JourneyMapGL'
|
||||||
|
|
||||||
|
// Lazy-load the GL renderer (and its ~230 KB gzip engine) so Leaflet-only
|
||||||
|
// installs never download it — it ships only once a GL provider is picked.
|
||||||
|
const JourneyMapGL = lazy(() => import('./JourneyMapGL'))
|
||||||
|
|
||||||
// Unified handle — both providers expose the same three methods.
|
// Unified handle — both providers expose the same three methods.
|
||||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||||
@@ -37,8 +41,9 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
|||||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||||
|
|
||||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||||
// supplied a token yet — otherwise the map would just show a stub.
|
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
|
||||||
const useGL = provider === 'mapbox-gl' && !!token
|
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
||||||
|
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||||
@@ -47,8 +52,12 @@ const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyM
|
|||||||
}), [useGL])
|
}), [useGL])
|
||||||
|
|
||||||
if (useGL) {
|
if (useGL) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
return (
|
||||||
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
<Suspense fallback={null}>
|
||||||
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
|
<JourneyMapGL ref={glRef} {...(props as any)} glProvider={glProvider} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||||
import mapboxgl from 'mapbox-gl'
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||||
|
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||||
|
|
||||||
export interface JourneyMapGLHandle {
|
export interface JourneyMapGLHandle {
|
||||||
highlightMarker: (id: string | null) => void
|
highlightMarker: (id: string | null) => void
|
||||||
@@ -32,6 +35,7 @@ interface Props {
|
|||||||
onMarkerClick?: (id: string, type?: string) => void
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
fullScreen?: boolean
|
fullScreen?: boolean
|
||||||
paddingBottom?: number
|
paddingBottom?: number
|
||||||
|
glProvider?: GlMapProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Item {
|
interface Item {
|
||||||
@@ -95,8 +99,10 @@ function ensureJourneyPopupStyle() {
|
|||||||
const s = document.createElement('style')
|
const s = document.createElement('style')
|
||||||
s.id = 'trek-journey-popup-style'
|
s.id = 'trek-journey-popup-style'
|
||||||
s.textContent = `
|
s.textContent = `
|
||||||
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
.mapboxgl-popup.trek-journey-popup,
|
||||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
|
||||||
|
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
|
||||||
padding: 9px 14px 10px;
|
padding: 9px 14px 10px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255, 255, 255, 0.94);
|
background: rgba(255, 255, 255, 0.94);
|
||||||
@@ -108,20 +114,24 @@ function ensureJourneyPopupStyle() {
|
|||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
|
||||||
|
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
|
||||||
background: rgba(24, 24, 27, 0.88);
|
background: rgba(24, 24, 27, 0.88);
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
color: #FAFAFA;
|
color: #FAFAFA;
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
|
||||||
|
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
|
||||||
border-top-color: rgba(255, 255, 255, 0.94);
|
border-top-color: rgba(255, 255, 255, 0.94);
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.94);
|
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
|
||||||
|
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
|
||||||
border-top-color: rgba(24, 24, 27, 0.88);
|
border-top-color: rgba(24, 24, 27, 0.88);
|
||||||
border-bottom-color: rgba(24, 24, 27, 0.88);
|
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
|
||||||
|
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
|
||||||
.trek-journey-popup-title {
|
.trek-journey-popup-title {
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -132,7 +142,8 @@ function ensureJourneyPopupStyle() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
|
||||||
|
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||||
.trek-journey-popup-sub {
|
.trek-journey-popup-sub {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -143,7 +154,8 @@ function ensureJourneyPopupStyle() {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
|
||||||
|
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||||
.trek-journey-popup-place {
|
.trek-journey-popup-place {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -194,20 +206,29 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
|
|||||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||||
|
|
||||||
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl' },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const stableTrail = trail || EMPTY_TRAIL
|
const stableTrail = trail || EMPTY_TRAIL
|
||||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||||
|
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||||
|
const mapLang = useSettingsStore(s => s.settings.language)
|
||||||
|
const isMapLibre = glProvider === 'maplibre-gl'
|
||||||
|
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||||
|
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||||
|
const enableMapbox3d = !isMapLibre && mapbox3d
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
const mapRef = useRef<any | null>(null)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const markersRef = useRef<Map<string, any>>(new Map())
|
||||||
const itemsRef = useRef<Item[]>([])
|
const itemsRef = useRef<Item[]>([])
|
||||||
const highlightedRef = useRef<string | null>(null)
|
const highlightedRef = useRef<string | null>(null)
|
||||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const popupRef = useRef<any | null>(null)
|
||||||
const onMarkerClickRef = useRef(onMarkerClick)
|
const onMarkerClickRef = useRef(onMarkerClick)
|
||||||
onMarkerClickRef.current = onMarkerClick
|
onMarkerClickRef.current = onMarkerClick
|
||||||
const darkRef = useRef(dark)
|
const darkRef = useRef(dark)
|
||||||
@@ -247,7 +268,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
const el = popupRef.current.getElement()
|
const el = popupRef.current.getElement()
|
||||||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||||
} else {
|
} else {
|
||||||
popupRef.current = new mapboxgl.Popup({
|
popupRef.current = new gl.Popup({
|
||||||
closeButton: false,
|
closeButton: false,
|
||||||
closeOnClick: false,
|
closeOnClick: false,
|
||||||
closeOnMove: false,
|
closeOnMove: false,
|
||||||
@@ -260,7 +281,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
.setHTML(html)
|
.setHTML(html)
|
||||||
.addTo(mapRef.current)
|
.addTo(mapRef.current)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [gl])
|
||||||
|
|
||||||
const hidePopup = useCallback(() => {
|
const hidePopup = useCallback(() => {
|
||||||
if (popupRef.current) {
|
if (popupRef.current) {
|
||||||
@@ -305,11 +326,11 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
mapRef.current.flyTo({
|
mapRef.current.flyTo({
|
||||||
center: marker.getLngLat(),
|
center: marker.getLngLat(),
|
||||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||||
pitch: mapbox3d ? 45 : 0,
|
pitch: enableMapbox3d ? 45 : 0,
|
||||||
duration: 600,
|
duration: 600,
|
||||||
})
|
})
|
||||||
} catch { /* map not yet ready */ }
|
} catch { /* map not yet ready */ }
|
||||||
}, [highlightMarker, mapbox3d])
|
}, [highlightMarker, enableMapbox3d])
|
||||||
|
|
||||||
const invalidateSize = useCallback(() => {
|
const invalidateSize = useCallback(() => {
|
||||||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||||
@@ -320,39 +341,46 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
// Build map once per style/token change. Markers and layers are rebuilt
|
// Build map once per style/token change. Markers and layers are rebuilt
|
||||||
// inside the same effect so they stay in sync with the active style.
|
// inside the same effect so they stay in sync with the active style.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !mapboxToken) return
|
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
||||||
mapboxgl.accessToken = mapboxToken
|
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
const items = buildItems(entries)
|
const items = buildItems(entries)
|
||||||
itemsRef.current = items
|
itemsRef.current = items
|
||||||
|
|
||||||
const bounds = new mapboxgl.LngLatBounds()
|
const bounds = new gl.LngLatBounds()
|
||||||
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||||
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||||
const hasPoints = items.length > 0 || stableTrail.length > 0
|
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||||
|
|
||||||
const map = new mapboxgl.Map({
|
const mapOptions: Record<string, unknown> = {
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: mapboxStyle,
|
style: glStyle,
|
||||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||||
zoom: hasPoints ? 2 : 1,
|
zoom: hasPoints ? 2 : 1,
|
||||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
antialias: mapboxQuality,
|
antialias: mapboxQuality,
|
||||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
}
|
||||||
})
|
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||||
|
|
||||||
|
const map = new gl.Map(mapOptions as any)
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
if (mapbox3d) {
|
if (enableMapbox3d) {
|
||||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
||||||
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
if (supportsCustom3d(glStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||||
}
|
}
|
||||||
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||||
// stay pinned to their coordinates at every zoom and pitch.
|
// stay pinned to their coordinates at every zoom and pitch.
|
||||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||||
try { map.setTerrain(null) } catch { /* noop */ }
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
|
// Pin the basemap label language to the UI language so labels don't fall back to the
|
||||||
|
// browser/OS locale and stack multiple scripts per place (#1299).
|
||||||
|
if (!isMapLibre && isStandardFamily(glStyle)) {
|
||||||
|
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support it */ }
|
||||||
|
}
|
||||||
|
|
||||||
// route trail — dashed line connecting entries in time order
|
// route trail — dashed line connecting entries in time order
|
||||||
if (items.length > 1) {
|
if (items.length > 1) {
|
||||||
@@ -383,7 +411,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
// markers
|
// markers
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new gl.Marker({ element: el, anchor: 'bottom' })
|
||||||
.setLngLat([item.lng, item.lat])
|
.setLngLat([item.lng, item.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
el.addEventListener('click', (ev) => {
|
el.addEventListener('click', (ev) => {
|
||||||
@@ -400,7 +428,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
map.fitBounds(bounds, {
|
map.fitBounds(bounds, {
|
||||||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||||
maxZoom: 16,
|
maxZoom: 16,
|
||||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
})
|
})
|
||||||
} catch { /* empty bounds */ }
|
} catch { /* empty bounds */ }
|
||||||
@@ -418,7 +446,7 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
try { map.remove() } catch { /* noop */ }
|
try { map.remove() } catch { /* noop */ }
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
}
|
}
|
||||||
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
}, [entries, stableTrail, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||||
|
|
||||||
// external activeMarkerId → highlight + flyTo
|
// external activeMarkerId → highlight + flyTo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -431,15 +459,15 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
mapRef.current.flyTo({
|
mapRef.current.flyTo({
|
||||||
center: marker.getLngLat(),
|
center: marker.getLngLat(),
|
||||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||||
duration: 500,
|
duration: 500,
|
||||||
})
|
})
|
||||||
} catch { /* map not ready */ }
|
} catch { /* map not ready */ }
|
||||||
}, 50)
|
}, 50)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
}, [activeMarkerId, highlightMarker, enableMapbox3d, fullScreen])
|
||||||
|
|
||||||
if (!mapboxToken) {
|
if (!isMapLibre && !mapboxToken) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
import MarkdownToolbar from './MarkdownToolbar';
|
import MarkdownToolbar from './MarkdownToolbar';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MarkdownToolbar', () => {
|
describe('MarkdownToolbar', () => {
|
||||||
let onUpdate: ReturnType<typeof vi.fn>;
|
let onUpdate: Mock<(value: string) => void>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
onUpdate = vi.fn();
|
onUpdate = vi.fn<(value: string) => void>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import { render } from '../../../tests/helpers/render'
|
||||||
|
import OfflineBanner from './OfflineBanner'
|
||||||
|
|
||||||
|
vi.mock('../../sync/mutationQueue', () => ({
|
||||||
|
mutationQueue: {
|
||||||
|
pendingCount: vi.fn(),
|
||||||
|
failedCount: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { mutationQueue } from '../../sync/mutationQueue'
|
||||||
|
|
||||||
|
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
|
||||||
|
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OfflineBanner (B3 surface)', () => {
|
||||||
|
it('shows the failed pill when failedCount > 0 while online', async () => {
|
||||||
|
pendingCount.mockResolvedValue(0)
|
||||||
|
failedCount.mockResolvedValue(2)
|
||||||
|
|
||||||
|
render(<OfflineBanner />)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays hidden when online with nothing pending or failed', async () => {
|
||||||
|
pendingCount.mockResolvedValue(0)
|
||||||
|
failedCount.mockResolvedValue(0)
|
||||||
|
|
||||||
|
const { container } = render(<OfflineBanner />)
|
||||||
|
// Give the async poll a tick to resolve.
|
||||||
|
await waitFor(() => expect(failedCount).toHaveBeenCalled())
|
||||||
|
expect(container.querySelector('[role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* OfflineBanner — connectivity + sync state indicator.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
|
* N failed → red pill "N changes failed to sync" (takes priority)
|
||||||
* offline + N queued → amber pill "Offline · N queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber pill "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue pill "Syncing N…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
* headers. On mobile it hovers just above the bottom tab bar.
|
* headers. On mobile it hovers just above the bottom tab bar.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||||
import { mutationQueue } from '../../sync/mutationQueue'
|
import { mutationQueue } from '../../sync/mutationQueue'
|
||||||
|
|
||||||
const POLL_MS = 3_000
|
const POLL_MS = 3_000
|
||||||
@@ -20,6 +21,7 @@ const POLL_MS = 3_000
|
|||||||
export default function OfflineBanner(): React.ReactElement | null {
|
export default function OfflineBanner(): React.ReactElement | null {
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [failedCount, setFailedCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOnline = () => setIsOnline(true)
|
const onOnline = () => setIsOnline(true)
|
||||||
@@ -35,26 +37,36 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function poll() {
|
async function poll() {
|
||||||
const n = await mutationQueue.pendingCount()
|
const [n, failed] = await Promise.all([
|
||||||
if (!cancelled) setPendingCount(n)
|
mutationQueue.pendingCount(),
|
||||||
|
mutationQueue.failedCount(),
|
||||||
|
])
|
||||||
|
if (!cancelled) {
|
||||||
|
setPendingCount(n)
|
||||||
|
setFailedCount(failed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
const id = setInterval(poll, POLL_MS)
|
const id = setInterval(poll, POLL_MS)
|
||||||
return () => { cancelled = true; clearInterval(id) }
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const hidden = isOnline && pendingCount === 0
|
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
||||||
if (hidden) return null
|
if (hidden) return null
|
||||||
|
|
||||||
const offline = !isOnline
|
const offline = !isOnline
|
||||||
const bg = offline ? '#92400e' : '#1e40af'
|
// Failed mutations are the most important signal — they mean data was dropped.
|
||||||
|
const failed = failedCount > 0
|
||||||
|
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
||||||
const text = '#fff'
|
const text = '#fff'
|
||||||
|
|
||||||
const label = offline
|
const label = failed
|
||||||
? pendingCount > 0
|
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
||||||
? `Offline · ${pendingCount} queued`
|
: offline
|
||||||
: 'Offline'
|
? pendingCount > 0
|
||||||
: `Syncing ${pendingCount}…`
|
? `Offline · ${pendingCount} queued`
|
||||||
|
: 'Offline'
|
||||||
|
: `Syncing ${pendingCount}…`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -82,9 +94,11 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{offline
|
{failed
|
||||||
? <WifiOff size={12} />
|
? <AlertTriangle size={12} />
|
||||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
: offline
|
||||||
|
? <WifiOff size={12} />
|
||||||
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Navigation } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface CompassMap {
|
||||||
|
getBearing: () => number
|
||||||
|
on: (type: 'rotate', listener: () => void) => unknown
|
||||||
|
off: (type: 'rotate', listener: () => void) => unknown
|
||||||
|
easeTo: (options: { bearing: number; pitch: number; duration: number }) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round compass pill for the GL planner map. The map can be rotated and
|
||||||
|
* pitched, so this shows the current bearing (the arrow points to north) and snaps
|
||||||
|
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
|
||||||
|
* (GL only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||||
|
* so its height and transparency match the POI pill exactly.
|
||||||
|
*/
|
||||||
|
export function MapCompassPill({ map }: { map: CompassMap }) {
|
||||||
|
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setBearing(map.getBearing())
|
||||||
|
update()
|
||||||
|
map.on('rotate', update)
|
||||||
|
return () => { map.off('rotate', update) }
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||||
|
background: 'var(--sidebar-bg)',
|
||||||
|
backdropFilter: 'blur(20px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||||
|
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
|
||||||
|
aria-label="Reset north"
|
||||||
|
className="text-content-muted"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', padding: 0,
|
||||||
|
transition: 'background 0.14s, color 0.14s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
@@ -10,6 +10,7 @@ import { mapsApi } from '../../api/client'
|
|||||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
import ReservationOverlay from './ReservationOverlay'
|
import ReservationOverlay from './ReservationOverlay'
|
||||||
import type { Reservation } from '../../types'
|
import type { Reservation } from '../../types'
|
||||||
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||||
|
|
||||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
@@ -118,6 +119,44 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
return fallbackIcon
|
return fallbackIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||||
|
// markers of planned places; the colour matches its pill category.
|
||||||
|
const poiIconCache = new Map<string, L.DivIcon>()
|
||||||
|
function createPoiIcon(category: string) {
|
||||||
|
const cached = poiIconCache.get(category)
|
||||||
|
if (cached) return cached
|
||||||
|
const cat = POI_CATEGORY_BY_KEY[category]
|
||||||
|
const color = cat?.color || '#6b7280'
|
||||||
|
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 13],
|
||||||
|
tooltipAnchor: [0, -14],
|
||||||
|
})
|
||||||
|
poiIconCache.set(category, icon)
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||||
|
// OSM places for the visible area.
|
||||||
|
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onViewportChange) return
|
||||||
|
const emit = () => {
|
||||||
|
const b = map.getBounds()
|
||||||
|
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||||
|
}
|
||||||
|
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
||||||
|
map.on('moveend', emit)
|
||||||
|
map.on('zoomend', emit)
|
||||||
|
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
|
||||||
|
}, [map, onViewportChange])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
interface SelectionControllerProps {
|
interface SelectionControllerProps {
|
||||||
places: Place[]
|
places: Place[]
|
||||||
selectedPlaceId: number | null
|
selectedPlaceId: number | null
|
||||||
@@ -367,7 +406,21 @@ export const MapView = memo(function MapView({
|
|||||||
showReservationStats = false,
|
showReservationStats = false,
|
||||||
visibleConnectionIds = [] as number[],
|
visibleConnectionIds = [] as number[],
|
||||||
onReservationClick,
|
onReservationClick,
|
||||||
|
pois = [] as Poi[],
|
||||||
|
onPoiClick,
|
||||||
|
onViewportChange,
|
||||||
}: any) {
|
}: any) {
|
||||||
|
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||||
|
<Marker
|
||||||
|
key={`poi-${poi.osm_id}`}
|
||||||
|
position={[poi.lat, poi.lng]}
|
||||||
|
icon={createPoiIcon(poi.category)}
|
||||||
|
zIndexOffset={500}
|
||||||
|
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
)), [pois, onPoiClick])
|
||||||
const visibleReservations = useMemo(() => {
|
const visibleReservations = useMemo(() => {
|
||||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||||
const set = new Set(visibleConnectionIds)
|
const set = new Set(visibleConnectionIds)
|
||||||
@@ -543,6 +596,7 @@ export const MapView = memo(function MapView({
|
|||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
|
<ViewportController onViewportChange={onViewportChange} />
|
||||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
@@ -583,6 +637,8 @@ export const MapView = memo(function MapView({
|
|||||||
showStats={showReservationStats}
|
showStats={showReservationStats}
|
||||||
onEndpointClick={onReservationClick}
|
onEndpointClick={onReservationClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{poiMarkers}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
{isMobile && <LocationButton
|
{isMobile && <LocationButton
|
||||||
mode={trackingMode}
|
mode={trackingMode}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { MapView } from './MapView'
|
import { MapView } from './MapView'
|
||||||
import { MapViewGL } from './MapViewGL'
|
|
||||||
|
// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so
|
||||||
|
// Leaflet-only installs never download it — it ships only once a GL provider is picked.
|
||||||
|
const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL })))
|
||||||
|
|
||||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||||
|
//
|
||||||
|
// Offline maps: only the Leaflet renderer supports full pre-download (raster
|
||||||
|
// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their
|
||||||
|
// vector tiles are cached opportunistically by the Service Worker as you view
|
||||||
|
// them online (see the GL tile rules in vite.config.js), not prefetched.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function MapViewAuto(props: any) {
|
export function MapViewAuto(props: any) {
|
||||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||||
// so trip planner never shows an empty map due to a missing token.
|
// so trip planner never shows an empty map due to a missing token.
|
||||||
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
|
||||||
|
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
|
||||||
|
: null
|
||||||
|
if (glProvider) {
|
||||||
|
// Render the previous Leaflet map as the fallback so there's no blank flash
|
||||||
|
// while the GL chunk loads on first use.
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<MapView {...props} />}>
|
||||||
|
<MapViewGL {...props} glProvider={glProvider} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
return <MapView {...props} />
|
return <MapView {...props} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,62 @@ const glMap = vi.hoisted(() => ({
|
|||||||
vi.mock('mapbox-gl', () => ({
|
vi.mock('mapbox-gl', () => ({
|
||||||
default: {
|
default: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
Map: vi.fn(() => glMap),
|
Map: vi.fn(function () {
|
||||||
Marker: vi.fn(() => ({
|
return glMap
|
||||||
setLngLat: vi.fn().mockReturnThis(),
|
}),
|
||||||
addTo: vi.fn().mockReturnThis(),
|
Marker: vi.fn(function () {
|
||||||
remove: vi.fn(),
|
return {
|
||||||
getElement: vi.fn(() => document.createElement('div')),
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
})),
|
addTo: vi.fn().mockReturnThis(),
|
||||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
remove: vi.fn(),
|
||||||
|
getElement: vi.fn(() => document.createElement('div')),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
LngLatBounds: vi.fn(function () {
|
||||||
|
return { extend: vi.fn().mockReturnThis() }
|
||||||
|
}),
|
||||||
NavigationControl: vi.fn(),
|
NavigationControl: vi.fn(),
|
||||||
|
Popup: vi.fn(function () {
|
||||||
|
return {
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
setHTML: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||||
|
|
||||||
|
vi.mock('maplibre-gl', () => ({
|
||||||
|
default: {
|
||||||
|
Map: vi.fn(function () {
|
||||||
|
return glMap
|
||||||
|
}),
|
||||||
|
Marker: vi.fn(function () {
|
||||||
|
return {
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
getElement: vi.fn(() => document.createElement('div')),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
LngLatBounds: vi.fn(function () {
|
||||||
|
return { extend: vi.fn().mockReturnThis() }
|
||||||
|
}),
|
||||||
|
NavigationControl: vi.fn(),
|
||||||
|
Popup: vi.fn(function () {
|
||||||
|
return {
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
setHTML: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||||
|
|
||||||
vi.mock('./mapboxSetup', () => ({
|
vi.mock('./mapboxSetup', () => ({
|
||||||
isStandardFamily: vi.fn(() => false),
|
isStandardFamily: vi.fn(() => false),
|
||||||
supportsCustom3d: vi.fn(() => false),
|
supportsCustom3d: vi.fn(() => false),
|
||||||
@@ -57,7 +100,9 @@ vi.mock('./locationMarkerMapbox', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./reservationsMapbox', () => ({
|
vi.mock('./reservationsMapbox', () => ({
|
||||||
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
ReservationMapboxOverlay: vi.fn(function () {
|
||||||
|
return { update: vi.fn() }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../hooks/useGeolocation', () => ({
|
vi.mock('../../hooks/useGeolocation', () => ({
|
||||||
@@ -161,4 +206,25 @@ describe('MapViewGL', () => {
|
|||||||
await act(async () => {})
|
await act(async () => {})
|
||||||
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
|
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
|
||||||
|
const mapboxgl = (await import('mapbox-gl')).default
|
||||||
|
const maplibregl = (await import('maplibre-gl')).default
|
||||||
|
useSettingsStore.setState({
|
||||||
|
settings: {
|
||||||
|
...useSettingsStore.getState().settings,
|
||||||
|
map_provider: 'maplibre-gl',
|
||||||
|
mapbox_access_token: '', // MapLibre/OpenFreeMap is tokenless — must not short-circuit
|
||||||
|
maplibre_style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
const places = [buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 })]
|
||||||
|
|
||||||
|
render(<MapViewGL places={places} fitKey={1} glProvider="maplibre-gl" />)
|
||||||
|
await act(async () => {})
|
||||||
|
|
||||||
|
// The MapLibre engine builds the map even without a token; Mapbox is not used.
|
||||||
|
expect(maplibregl.Map).toHaveBeenCalled()
|
||||||
|
expect(mapboxgl.Map).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import mapboxgl from 'mapbox-gl'
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
@@ -9,9 +11,12 @@ import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
|||||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||||
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||||
|
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||||
import LocationButton from './LocationButton'
|
import LocationButton from './LocationButton'
|
||||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||||
import type { Place, Reservation } from '../../types'
|
import type { Place, Reservation } from '../../types'
|
||||||
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||||
|
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
|
||||||
|
|
||||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
@@ -49,6 +54,12 @@ interface Props {
|
|||||||
visibleConnectionIds?: number[]
|
visibleConnectionIds?: number[]
|
||||||
showReservationStats?: boolean
|
showReservationStats?: boolean
|
||||||
onReservationClick?: (reservationId: number) => void
|
onReservationClick?: (reservationId: number) => void
|
||||||
|
pois?: Poi[]
|
||||||
|
onPoiClick?: (poi: Poi) => void
|
||||||
|
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||||
|
glProvider?: GlMapProvider
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onMapReady?: (map: any | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||||
@@ -85,8 +96,8 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wrap = document.createElement('div')
|
const wrap = document.createElement('div')
|
||||||
// Do NOT set `position: relative` here — mapbox-gl ships
|
// Do NOT set `position: relative` here — GL map libraries ship
|
||||||
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
// marker classes with `position: absolute` and rely on it. An inline
|
||||||
// `position: relative` here overrides the class, turns every marker into
|
// `position: relative` here overrides the class, turns every marker into
|
||||||
// a static block element, and stacks them in document order inside the
|
// a static block element, and stacks them in document order inside the
|
||||||
// canvas container. The result looks exactly like "markers drift as the
|
// canvas container. The result looks exactly like "markers drift as the
|
||||||
@@ -128,6 +139,17 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
|||||||
return wrap
|
return wrap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||||
|
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||||
|
const cat = POI_CATEGORY_BY_KEY[category]
|
||||||
|
const color = cat?.color || '#6b7280'
|
||||||
|
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||||
|
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
export function MapViewGL({
|
export function MapViewGL({
|
||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
@@ -149,56 +171,93 @@ export function MapViewGL({
|
|||||||
visibleConnectionIds = [],
|
visibleConnectionIds = [],
|
||||||
showReservationStats = false,
|
showReservationStats = false,
|
||||||
onReservationClick,
|
onReservationClick,
|
||||||
|
pois = [],
|
||||||
|
onPoiClick,
|
||||||
|
onViewportChange,
|
||||||
|
glProvider = 'mapbox-gl',
|
||||||
|
onMapReady,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||||
|
const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '')
|
||||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
||||||
|
const mapLang = useSettingsStore(s => s.settings.language)
|
||||||
|
const isMapLibre = glProvider === 'maplibre-gl'
|
||||||
|
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||||
|
const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle)
|
||||||
|
const enableMapbox3d = !isMapLibre && mapbox3d
|
||||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||||
const [mapReady, setMapReady] = useState(false)
|
const [mapReady, setMapReady] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
const mapRef = useRef<any | null>(null)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const markersRef = useRef<Map<number, any>>(new Map())
|
||||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||||
// Refs so the reservation overlay always sees the latest callback /
|
// Refs so the reservation overlay always sees the latest callback /
|
||||||
// options without forcing a full overlay rebuild on every prop change.
|
// options without forcing a full overlay rebuild on every prop change.
|
||||||
const onReservationClickRef = useRef(onReservationClick)
|
const onReservationClickRef = useRef(onReservationClick)
|
||||||
onReservationClickRef.current = onReservationClick
|
onReservationClickRef.current = onReservationClick
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const poiMarkersRef = useRef<any[]>([])
|
||||||
|
// Single reusable hover popup (name/category/address card) shared by planned
|
||||||
|
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const popupRef = useRef<any | null>(null)
|
||||||
|
const onPoiClickRef = useRef(onPoiClick)
|
||||||
|
onPoiClickRef.current = onPoiClick
|
||||||
|
const onViewportChangeRef = useRef(onViewportChange)
|
||||||
|
onViewportChangeRef.current = onViewportChange
|
||||||
|
const onMapReadyRef = useRef(onMapReady)
|
||||||
|
onMapReadyRef.current = onMapReady
|
||||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||||
onClickRefs.current.marker = onMarkerClick
|
onClickRefs.current.marker = onMarkerClick
|
||||||
onClickRefs.current.map = onMapClick
|
onClickRefs.current.map = onMapClick
|
||||||
onClickRefs.current.context = onMapContextMenu
|
onClickRefs.current.context = onMapContextMenu
|
||||||
|
|
||||||
// Build/rebuild the map on style/token/3d change
|
// Build/rebuild the map on provider/style/token/3d change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !mapboxToken) return
|
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
||||||
mapboxgl.accessToken = mapboxToken
|
if (!isMapLibre) mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
const map = new mapboxgl.Map({
|
const mapOptions: Record<string, unknown> = {
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: mapboxStyle,
|
style: glStyle,
|
||||||
center: [center[1], center[0]],
|
center: [center[1], center[0]],
|
||||||
zoom,
|
zoom,
|
||||||
pitch: mapbox3d ? 45 : 0,
|
pitch: enableMapbox3d ? 45 : 0,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
antialias: mapboxQuality,
|
antialias: mapboxQuality,
|
||||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
}
|
||||||
})
|
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||||
|
|
||||||
|
const map = new gl.Map(mapOptions as any)
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
|
popupRef.current = new gl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
offset: 18,
|
||||||
|
maxWidth: '240px',
|
||||||
|
className: 'trek-map-popup',
|
||||||
|
})
|
||||||
|
// Hand the map out so the trip planner can render its own compass pill next to
|
||||||
|
// the POI pill (a custom round control instead of Mapbox's default top-right one).
|
||||||
|
onMapReadyRef.current?.(map)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
;(window as any).__trek_map = map
|
;(window as any).__trek_map = map
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
if (mapbox3d) {
|
if (enableMapbox3d) {
|
||||||
// Terrain is only valuable on satellite styles — on clean vector
|
// Terrain is only valuable on satellite styles — on clean vector
|
||||||
// styles it makes route lines drift off the HTML markers because
|
// styles it makes route lines drift off the HTML markers because
|
||||||
// the lines snap to DEM height while markers stay at sea level.
|
// the lines snap to DEM height while markers stay at sea level.
|
||||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
if (!isStandardFamily(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
||||||
if (supportsCustom3d(mapboxStyle)) {
|
if (supportsCustom3d(glStyle)) {
|
||||||
const dark = document.documentElement.classList.contains('dark')
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
addCustom3dBuildings(map, dark)
|
addCustom3dBuildings(map, dark)
|
||||||
}
|
}
|
||||||
@@ -211,7 +270,7 @@ export function MapViewGL({
|
|||||||
// non-satellite Standard style still looks great without terrain,
|
// non-satellite Standard style still looks great without terrain,
|
||||||
// so flatten it out to keep markers pinned. (Satellite variants
|
// so flatten it out to keep markers pinned. (Satellite variants
|
||||||
// are left alone — the DEM is what gives them their character.)
|
// are left alone — the DEM is what gives them their character.)
|
||||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||||
try { map.setTerrain(null) } catch { /* noop */ }
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
// initial route source — kept around so updates can setData() cheaply
|
// initial route source — kept around so updates can setData() cheaply
|
||||||
@@ -257,10 +316,18 @@ export function MapViewGL({
|
|||||||
|
|
||||||
map.on('click', (e) => {
|
map.on('click', (e) => {
|
||||||
const t = e.originalEvent.target as HTMLElement
|
const t = e.originalEvent.target as HTMLElement
|
||||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
|
||||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||||
})
|
})
|
||||||
// In the mapbox-gl map the right mouse button is reserved for the
|
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||||
|
// pill can fetch OSM places for the visible area.
|
||||||
|
const emitViewport = () => {
|
||||||
|
const b = map.getBounds()
|
||||||
|
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||||
|
}
|
||||||
|
map.on('moveend', emitViewport)
|
||||||
|
map.once('idle', emitViewport)
|
||||||
|
// In the GL map the right mouse button is reserved for the
|
||||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||||
// to the middle mouse button (button === 1) instead.
|
// to the middle mouse button (button === 1) instead.
|
||||||
const canvas = map.getCanvasContainer()
|
const canvas = map.getCanvasContainer()
|
||||||
@@ -307,7 +374,9 @@ export function MapViewGL({
|
|||||||
const ll = marker.getLngLat()
|
const ll = marker.getLngLat()
|
||||||
let alt = 0
|
let alt = 0
|
||||||
try {
|
try {
|
||||||
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
const e = typeof map.queryTerrainElevation === 'function'
|
||||||
|
? map.queryTerrainElevation([ll.lng, ll.lat])
|
||||||
|
: null
|
||||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||||
} catch { /* terrain not ready */ }
|
} catch { /* terrain not ready */ }
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -319,13 +388,17 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
map.on('render', syncMarkerAltitudes)
|
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame
|
||||||
|
// listener entirely for MapLibre and flat mapbox styles.
|
||||||
|
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener('mousedown', onAuxDown)
|
canvas.removeEventListener('mousedown', onAuxDown)
|
||||||
canvas.removeEventListener('auxclick', onAuxClick)
|
canvas.removeEventListener('auxclick', onAuxClick)
|
||||||
markersRef.current.forEach(m => m.remove())
|
markersRef.current.forEach(m => m.remove())
|
||||||
markersRef.current.clear()
|
markersRef.current.clear()
|
||||||
|
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||||
|
onMapReadyRef.current?.(null)
|
||||||
if (reservationOverlayRef.current) {
|
if (reservationOverlayRef.current) {
|
||||||
reservationOverlayRef.current.destroy()
|
reservationOverlayRef.current.destroy()
|
||||||
reservationOverlayRef.current = null
|
reservationOverlayRef.current = null
|
||||||
@@ -338,7 +411,17 @@ export function MapViewGL({
|
|||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
setMapReady(false)
|
setMapReady(false)
|
||||||
}
|
}
|
||||||
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
|
||||||
|
|
||||||
|
// Pin the basemap label language to the UI language so labels don't fall back to the
|
||||||
|
// browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299).
|
||||||
|
// Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles
|
||||||
|
// are left as-is. Runs on load (mapReady) and whenever the UI language changes.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return
|
||||||
|
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ }
|
||||||
|
}, [mapLang, mapReady, isMapLibre, glStyle])
|
||||||
|
|
||||||
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||||
// simultaneous thumb arrivals into one re-render.
|
// simultaneous thumb arrivals into one re-render.
|
||||||
@@ -399,6 +482,10 @@ export function MapViewGL({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map) return
|
if (!map) return
|
||||||
|
// Markers are about to be rebuilt; drop any open hover popup first. A marker
|
||||||
|
// recreated under the pointer (e.g. when its photo streams in) never fires
|
||||||
|
// mouseleave, which would otherwise leave the popup orphaned on the map.
|
||||||
|
popupRef.current?.remove()
|
||||||
const ids = new Set(places.map(p => p.id))
|
const ids = new Set(places.map(p => p.id))
|
||||||
|
|
||||||
markersRef.current.forEach((marker, id) => {
|
markersRef.current.forEach((marker, id) => {
|
||||||
@@ -419,6 +506,12 @@ export function MapViewGL({
|
|||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
onClickRefs.current.marker?.(place.id)
|
onClickRefs.current.marker?.(place.id)
|
||||||
})
|
})
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
popupRef.current?.setLngLat([place.lng, place.lat])
|
||||||
|
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||||
// Recreate marker each time rather than patching internal state —
|
// Recreate marker each time rather than patching internal state —
|
||||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||||
const existing = markersRef.current.get(place.id)
|
const existing = markersRef.current.get(place.id)
|
||||||
@@ -428,12 +521,32 @@ export function MapViewGL({
|
|||||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||||
// but it rotates the element by the pitch angle and visually offsets
|
// but it rotates the element by the pitch angle and visually offsets
|
||||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
const m = new gl.Marker({ element: el, anchor: 'center' })
|
||||||
.setLngLat([place.lng, place.lat])
|
.setLngLat([place.lng, place.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
markersRef.current.set(place.id, m)
|
markersRef.current.set(place.id, m)
|
||||||
})
|
})
|
||||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
|
||||||
|
|
||||||
|
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||||
|
// planned-place markers so they don't cluster or get confused with them).
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map || !mapReady) return
|
||||||
|
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||||
|
poiMarkersRef.current.forEach(m => m.remove())
|
||||||
|
poiMarkersRef.current = []
|
||||||
|
for (const poi of (pois as Poi[])) {
|
||||||
|
const el = createPoiMarkerElement(poi.category)
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||||
|
})
|
||||||
|
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||||
|
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||||
|
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||||
|
poiMarkersRef.current.push(m)
|
||||||
|
}
|
||||||
|
}, [pois, mapReady, glProvider])
|
||||||
|
|
||||||
// Update route geojson
|
// Update route geojson
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -497,7 +610,7 @@ export function MapViewGL({
|
|||||||
showStats: showReservationStats,
|
showStats: showReservationStats,
|
||||||
showEndpointLabels,
|
showEndpointLabels,
|
||||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||||
})
|
}, gl.Marker as any)
|
||||||
}
|
}
|
||||||
reservationOverlayRef.current.update(visibleReservations, {
|
reservationOverlayRef.current.update(visibleReservations, {
|
||||||
showConnections: true,
|
showConnections: true,
|
||||||
@@ -505,7 +618,7 @@ export function MapViewGL({
|
|||||||
showEndpointLabels,
|
showEndpointLabels,
|
||||||
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
||||||
})
|
})
|
||||||
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
|
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
||||||
|
|
||||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||||
const paddingOpts = useMemo(() => {
|
const paddingOpts = useMemo(() => {
|
||||||
@@ -525,14 +638,14 @@ export function MapViewGL({
|
|||||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||||
const valid = target.filter(p => p.lat && p.lng)
|
const valid = target.filter(p => p.lat && p.lng)
|
||||||
if (valid.length === 0) return
|
if (valid.length === 0) return
|
||||||
const bounds = new mapboxgl.LngLatBounds()
|
const bounds = new gl.LngLatBounds()
|
||||||
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||||
const run = () => {
|
const run = () => {
|
||||||
try {
|
try {
|
||||||
map.fitBounds(bounds, {
|
map.fitBounds(bounds, {
|
||||||
padding: paddingOpts,
|
padding: paddingOpts,
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
pitch: mapbox3d ? 45 : 0,
|
pitch: enableMapbox3d ? 45 : 0,
|
||||||
duration: 400,
|
duration: 400,
|
||||||
})
|
})
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
@@ -551,7 +664,7 @@ export function MapViewGL({
|
|||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: [target.lng, target.lat],
|
center: [target.lng, target.lat],
|
||||||
zoom: Math.max(map.getZoom(), 14),
|
zoom: Math.max(map.getZoom(), 14),
|
||||||
pitch: mapbox3d ? 45 : 0,
|
pitch: enableMapbox3d ? 45 : 0,
|
||||||
duration: 400,
|
duration: 400,
|
||||||
// Account for the side panels and the bottom inspector / day-detail panel
|
// Account for the side panels and the bottom inspector / day-detail panel
|
||||||
// so the selected pin lands in the centre of the *visible* map area rather
|
// so the selected pin lands in the centre of the *visible* map area rather
|
||||||
@@ -559,7 +672,7 @@ export function MapViewGL({
|
|||||||
padding: paddingOpts,
|
padding: paddingOpts,
|
||||||
})
|
})
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [selectedPlaceId, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// External center/zoom prop changes — jump without animation
|
// External center/zoom prop changes — jump without animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -582,7 +695,7 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
if (!userPosition) return
|
if (!userPosition) return
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map, gl.Marker as any)
|
||||||
locationMarkerRef.current.update(userPosition)
|
locationMarkerRef.current.update(userPosition)
|
||||||
if (trackingMode === 'follow') {
|
if (trackingMode === 'follow') {
|
||||||
// easeTo is gentler than flyTo for continuous updates
|
// easeTo is gentler than flyTo for continuous updates
|
||||||
@@ -598,9 +711,9 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
if (map.loaded()) apply()
|
if (map.loaded()) apply()
|
||||||
else map.once('load', apply)
|
else map.once('load', apply)
|
||||||
}, [userPosition, trackingMode])
|
}, [userPosition, trackingMode, glProvider])
|
||||||
|
|
||||||
if (!mapboxToken) {
|
if (!isMapLibre && !mapboxToken) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||||
<div className="text-sm text-zinc-500">
|
<div className="text-sm text-zinc-500">
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { RotateCw, AlertTriangle } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
|
import { POI_CATEGORIES } from './poiCategories'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
active: Set<string>
|
||||||
|
onToggle: (key: string) => void
|
||||||
|
loadingKeys?: Set<string>
|
||||||
|
/** categories whose last fetch failed → show a retry affordance */
|
||||||
|
errorKeys?: Set<string>
|
||||||
|
/** true when the map moved since the last search → offer "search this area" */
|
||||||
|
moved?: boolean
|
||||||
|
onSearchArea?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||||
|
// fill with the category colour (matching their markers); the label shows in a
|
||||||
|
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||||
|
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
||||||
|
|
||||||
|
const frosted: React.CSSProperties = {
|
||||||
|
background: 'var(--sidebar-bg)',
|
||||||
|
backdropFilter: 'blur(20px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||||
|
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
|
||||||
|
{POI_CATEGORIES.map(cat => {
|
||||||
|
const on = active.has(cat.key)
|
||||||
|
const loading = loadingKeys?.has(cat.key)
|
||||||
|
return (
|
||||||
|
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(cat.key)}
|
||||||
|
aria-pressed={on}
|
||||||
|
aria-label={t(cat.labelKey)}
|
||||||
|
className={on ? '' : 'text-content-muted'}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
|
background: on ? cat.color : 'transparent',
|
||||||
|
color: on ? '#fff' : undefined,
|
||||||
|
transition: 'background 0.14s, color 0.14s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!on) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span
|
||||||
|
className="animate-spin"
|
||||||
|
style={{
|
||||||
|
width: 14, height: 14, borderRadius: 999, display: 'inline-block',
|
||||||
|
border: '2px solid', borderColor: on ? 'rgba(255,255,255,0.45)' : 'var(--border-primary)',
|
||||||
|
borderTopColor: on ? '#fff' : 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<cat.Icon size={16} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
{on && !loading && errorKeys?.has(cat.key) && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
|
||||||
|
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(moved || anyError) && active.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSearchArea}
|
||||||
|
className="text-content"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
||||||
|
color: anyError ? '#ef4444' : undefined,
|
||||||
|
...frosted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{anyError
|
||||||
|
? <AlertTriangle size={13} strokeWidth={2.4} />
|
||||||
|
: <RotateCw size={13} strokeWidth={2.4} />}
|
||||||
|
{t('poi.searchThisArea')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -158,6 +158,7 @@ interface TransportItem {
|
|||||||
res: Reservation
|
res: Reservation
|
||||||
from: ReservationEndpoint
|
from: ReservationEndpoint
|
||||||
to: ReservationEndpoint
|
to: ReservationEndpoint
|
||||||
|
waypoints: ReservationEndpoint[]
|
||||||
type: TransportType
|
type: TransportType
|
||||||
arcs: [number, number][][]
|
arcs: [number, number][][]
|
||||||
primaryArc: [number, number][]
|
primaryArc: [number, number][]
|
||||||
@@ -353,15 +354,29 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
const out: TransportItem[] = []
|
const out: TransportItem[] = []
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
const eps = r.endpoints || []
|
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
|
||||||
const from = eps.find(e => e.role === 'from')
|
// so the arc + markers below are byte-identical to before for it.
|
||||||
const to = eps.find(e => e.role === 'to')
|
const waypoints = (r.endpoints || [])
|
||||||
if (!from || !to) continue
|
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||||
|
if (waypoints.length < 2) continue
|
||||||
|
const from = waypoints[0]
|
||||||
|
const to = waypoints[waypoints.length - 1]
|
||||||
const type = r.type as TransportType
|
const type = r.type as TransportType
|
||||||
const isGeo = TYPE_META[type].geodesic
|
const isGeo = TYPE_META[type].geodesic
|
||||||
const arcs = isGeo
|
// One arc per leg (between consecutive waypoints), concatenated.
|
||||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
const arcs: [number, number][][] = []
|
||||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
let distanceKm = 0
|
||||||
|
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||||
|
const a = waypoints[i]
|
||||||
|
const b = waypoints[i + 1]
|
||||||
|
const segArcs = isGeo
|
||||||
|
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||||
|
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||||
|
arcs.push(...segArcs)
|
||||||
|
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||||
|
}
|
||||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
const primaryArc = arcs[primaryIdx] ?? []
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
const fallback: [number, number] = primaryArc.length > 0
|
const fallback: [number, number] = primaryArc.length > 0
|
||||||
@@ -369,12 +384,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
||||||
|
|
||||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
const distance = `${Math.round(distanceKm)} km`
|
||||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
// Show the full route (FRA → BER → HND) when every waypoint has a code.
|
||||||
|
const mainLabel = waypoints.every(w => w.code)
|
||||||
|
? waypoints.map(w => w.code).join(' → ')
|
||||||
|
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
||||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||||
|
|
||||||
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}, [reservations])
|
}, [reservations])
|
||||||
@@ -416,38 +434,21 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
/>
|
/>
|
||||||
)))}
|
)))}
|
||||||
|
|
||||||
{visibleItems.flatMap(item => [
|
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={`from-${item.res.id}`}
|
key={`wp-${item.res.id}-${wi}`}
|
||||||
position={[item.from.lat, item.from.lng]}
|
position={[wp.lat, wp.lng]}
|
||||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
|
||||||
pane={ENDPOINT_PANE}
|
pane={ENDPOINT_PANE}
|
||||||
zIndexOffset={1000}
|
zIndexOffset={1000}
|
||||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div>
|
||||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>,
|
</Marker>
|
||||||
<Marker
|
)))}
|
||||||
key={`to-${item.res.id}`}
|
|
||||||
position={[item.to.lat, item.to.lng]}
|
|
||||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
|
|
||||||
pane={ENDPOINT_PANE}
|
|
||||||
zIndexOffset={1000}
|
|
||||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
|
||||||
>
|
|
||||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
|
||||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
|
||||||
</Tooltip>
|
|
||||||
</Marker>,
|
|
||||||
])}
|
|
||||||
|
|
||||||
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
|
|
||||||
<StatsLabel key={`stats-${item.res.id}`} item={item} />
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
calculateSegments,
|
calculateSegments,
|
||||||
optimizeRoute,
|
optimizeRoute,
|
||||||
generateGoogleMapsUrl,
|
generateGoogleMapsUrl,
|
||||||
|
withHotelBookends,
|
||||||
} from './RouteCalculator'
|
} from './RouteCalculator'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
@@ -241,3 +242,46 @@ describe('generateGoogleMapsUrl', () => {
|
|||||||
expect(result).toContain('48.86,2.36')
|
expect(result).toContain('48.86,2.36')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
|
||||||
|
|
||||||
|
describe('withHotelBookends', () => {
|
||||||
|
const hotel = { lat: 1, lng: 1 }
|
||||||
|
const a = { lat: 2, lng: 2 }
|
||||||
|
const b = { lat: 3, lng: 3 }
|
||||||
|
const evening = { lat: 4, lng: 4 }
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
|
||||||
|
[hotel, a],
|
||||||
|
[a, b],
|
||||||
|
[b, evening],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
|
||||||
|
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
|
||||||
|
[hotel, a],
|
||||||
|
[a, evening],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
|
||||||
|
const runs = [[a, b]]
|
||||||
|
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
|
||||||
|
[hotel, a],
|
||||||
|
[a, b],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||||
|
import { formatDistance } from '../../utils/units'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
@@ -60,13 +62,34 @@ export async function calculateRoute(
|
|||||||
coordinates,
|
coordinates,
|
||||||
distance,
|
distance,
|
||||||
duration,
|
duration,
|
||||||
distanceText: formatDistance(distance),
|
distanceText: formatRouteDistance(distance),
|
||||||
durationText: formatDuration(duration),
|
durationText: formatDuration(duration),
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(drivingDuration),
|
drivingText: formatDuration(drivingDuration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
|
||||||
|
* day's activity runs, so the drawn route starts and ends at the day's accommodation
|
||||||
|
* (matching the sidebar's hotel connectors). A bookend is only added when both its
|
||||||
|
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
|
||||||
|
* untouched. The shared first/last waypoint is repeated so the polylines join.
|
||||||
|
*/
|
||||||
|
export function withHotelBookends(
|
||||||
|
runs: Waypoint[][],
|
||||||
|
firstWay: Waypoint | undefined,
|
||||||
|
lastWay: Waypoint | undefined,
|
||||||
|
startHotel: Waypoint | null,
|
||||||
|
endHotel: Waypoint | null,
|
||||||
|
): Waypoint[][] {
|
||||||
|
const out: Waypoint[][] = []
|
||||||
|
if (startHotel && firstWay) out.push([startHotel, firstWay])
|
||||||
|
out.push(...runs)
|
||||||
|
if (endHotel && lastWay) out.push([lastWay, endHotel])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||||
const valid = places.filter((p) => p.lat && p.lng)
|
const valid = places.filter((p) => p.lat && p.lng)
|
||||||
if (valid.length === 0) return null
|
if (valid.length === 0) return null
|
||||||
@@ -197,7 +220,7 @@ export async function calculateSegments(
|
|||||||
duration: leg.duration,
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
distanceText: formatDistance(leg.distance),
|
distanceText: formatRouteDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -217,7 +240,9 @@ export async function calculateRouteWithLegs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
const cacheKey = `${profile}:${coords}`
|
// The cached result carries formatted leg distances, so the active distance unit is
|
||||||
|
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||||
|
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
|
||||||
const cached = routeCache.get(cacheKey)
|
const cached = routeCache.get(cacheKey)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
@@ -244,7 +269,7 @@ export async function calculateRouteWithLegs(
|
|||||||
duration: leg.duration,
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
distanceText: formatDistance(leg.distance),
|
distanceText: formatRouteDistance(leg.distance),
|
||||||
durationText: formatDuration(leg.duration),
|
durationText: formatDuration(leg.duration),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,11 +284,16 @@ export async function calculateRouteWithLegs(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDistance(meters: number): string {
|
function getDistanceUnit(): DistanceUnit {
|
||||||
if (meters < 1000) {
|
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRouteDistance(meters: number): string {
|
||||||
|
const unit = getDistanceUnit()
|
||||||
|
if (unit === 'metric' && meters < 1000) {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
}
|
}
|
||||||
return `${(meters / 1000).toFixed(1)} km`
|
return formatDistance(meters / 1000, unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
MAPBOX_DEFAULT_STYLE,
|
||||||
|
OPENFREEMAP_DEFAULT_STYLE,
|
||||||
|
isOpenFreeMapStyle,
|
||||||
|
normalizeStyleForProvider,
|
||||||
|
styleForActiveProvider,
|
||||||
|
basemapLanguage,
|
||||||
|
} from './glProviders'
|
||||||
|
|
||||||
|
describe('glProviders', () => {
|
||||||
|
it('keeps OpenFreeMap styles for MapLibre', () => {
|
||||||
|
const style = 'https://tiles.openfreemap.org/styles/bright'
|
||||||
|
|
||||||
|
expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
|
||||||
|
expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
|
||||||
|
OPENFREEMAP_DEFAULT_STYLE,
|
||||||
|
)
|
||||||
|
expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves Mapbox styles unchanged for Mapbox GL', () => {
|
||||||
|
expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches the OpenFreeMap CSP host', () => {
|
||||||
|
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
|
||||||
|
expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects host/userinfo spoofing and http downgrade', () => {
|
||||||
|
expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
|
||||||
|
expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
|
||||||
|
expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
|
||||||
|
expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to provider defaults for empty/whitespace styles', () => {
|
||||||
|
expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||||
|
expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||||
|
expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
|
||||||
|
expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('styleForActiveProvider reads each provider\'s own style slot', () => {
|
||||||
|
const mb = 'mapbox://styles/me/custom'
|
||||||
|
const ofm = 'https://tiles.openfreemap.org/styles/bright'
|
||||||
|
expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
|
||||||
|
expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
|
||||||
|
// An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
|
||||||
|
expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => {
|
||||||
|
// Pass-through for plain ISO 639-1 codes.
|
||||||
|
expect(basemapLanguage('en')).toBe('en')
|
||||||
|
expect(basemapLanguage('de')).toBe('de')
|
||||||
|
expect(basemapLanguage('fr')).toBe('fr')
|
||||||
|
// TREK-specific overrides.
|
||||||
|
expect(basemapLanguage('br')).toBe('pt')
|
||||||
|
expect(basemapLanguage('gr')).toBe('el')
|
||||||
|
expect(basemapLanguage('zh')).toBe('zh-Hans')
|
||||||
|
expect(basemapLanguage('zhTw')).toBe('zh-Hant')
|
||||||
|
expect(basemapLanguage('zh-TW')).toBe('zh-Hant')
|
||||||
|
// Falls back to English when unset.
|
||||||
|
expect(basemapLanguage(undefined)).toBe('en')
|
||||||
|
expect(basemapLanguage('')).toBe('en')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
|
||||||
|
|
||||||
|
export interface GlStylePreset {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
|
||||||
|
export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
|
||||||
|
|
||||||
|
export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
|
||||||
|
{ name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
|
||||||
|
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
|
||||||
|
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
|
||||||
|
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
|
||||||
|
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
|
||||||
|
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
|
||||||
|
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
|
||||||
|
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
|
||||||
|
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
|
||||||
|
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
|
||||||
|
{ name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
|
||||||
|
{ name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
|
||||||
|
{ name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
|
||||||
|
return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultStyleForProvider(provider: GlMapProvider): string {
|
||||||
|
return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenFreeMapStyle(style?: string | null): boolean {
|
||||||
|
return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
|
||||||
|
const trimmed = (style || '').trim()
|
||||||
|
if (!trimmed) return defaultStyleForProvider(provider)
|
||||||
|
if (provider === 'maplibre-gl') {
|
||||||
|
return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The settings key that holds the style for a given GL provider. */
|
||||||
|
export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
|
||||||
|
return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
|
||||||
|
* switching providers never overwrites the other one's custom style. Picks and normalizes
|
||||||
|
* the style for the active provider.
|
||||||
|
*/
|
||||||
|
export function styleForActiveProvider(
|
||||||
|
provider: GlMapProvider,
|
||||||
|
mapboxStyle?: string | null,
|
||||||
|
maplibreStyle?: string | null,
|
||||||
|
): string {
|
||||||
|
return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A few TREK UI language codes differ from what the GL basemap expects for its labels.
|
||||||
|
const BASEMAP_LANG_OVERRIDES: Record<string, string> = {
|
||||||
|
br: 'pt', // TREK 'br' = Brazilian Portuguese
|
||||||
|
gr: 'el', // TREK 'gr' = Greek
|
||||||
|
zh: 'zh-Hans',
|
||||||
|
zhTw: 'zh-Hant',
|
||||||
|
'zh-TW': 'zh-Hant',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a TREK UI language code to the label language the GL basemap expects. Used to pin
|
||||||
|
* Mapbox Standard's basemap labels to the user's language so they don't fall back to the
|
||||||
|
* browser/OS locale and stack multiple scripts per place (#1299).
|
||||||
|
*/
|
||||||
|
export function basemapLanguage(uiLang: string | undefined): string {
|
||||||
|
const code = (uiLang || 'en').trim()
|
||||||
|
return BASEMAP_LANG_OVERRIDES[code] ?? code
|
||||||
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import mapboxgl from 'mapbox-gl'
|
import type mapboxgl from 'mapbox-gl'
|
||||||
import type { GeoPosition } from '../../hooks/useGeolocation'
|
import type { GeoPosition } from '../../hooks/useGeolocation'
|
||||||
|
|
||||||
|
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
|
||||||
|
setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
|
||||||
|
addTo: (map: mapboxgl.Map) => unknown
|
||||||
|
remove: () => void
|
||||||
|
getElement: () => HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
// Build the DOM element that backs the mapbox Marker. We animate the
|
// Build the DOM element that backs the mapbox Marker. We animate the
|
||||||
// heading cone via a CSS rotation so the DOM stays stable across updates
|
// heading cone via a CSS rotation so the DOM stays stable across updates
|
||||||
// and mapbox doesn't get confused about which element to position.
|
// and mapbox doesn't get confused about which element to position.
|
||||||
@@ -66,10 +73,10 @@ export interface LocationMarkerHandle {
|
|||||||
// mapbox map. Returns a handle the caller uses to push position updates
|
// mapbox map. Returns a handle the caller uses to push position updates
|
||||||
// and clean up. Keeps its own DOM element and GeoJSON source so it can
|
// and clean up. Keeps its own DOM element and GeoJSON source so it can
|
||||||
// coexist with the regular trip markers.
|
// coexist with the regular trip markers.
|
||||||
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
|
export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
|
||||||
ensurePulseStyle()
|
ensurePulseStyle()
|
||||||
const { root, cone } = buildLocationEl()
|
const { root, cone } = buildLocationEl()
|
||||||
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
|
const marker = new MarkerCtor({ element: root, anchor: 'center' })
|
||||||
|
|
||||||
const ensureAccuracyLayer = () => {
|
const ensureAccuracyLayer = () => {
|
||||||
if (map.getSource('trek-location-accuracy')) return
|
if (map.getSource('trek-location-accuracy')) return
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||||
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
|
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
|
||||||
|
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
|
||||||
|
// no equivalent, so these produce the same card as an HTML string for a
|
||||||
|
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
|
||||||
|
|
||||||
|
type PlaceWithCategory = Place & {
|
||||||
|
category_color?: string | null
|
||||||
|
category_icon?: string | null
|
||||||
|
category_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string | null | undefined): string {
|
||||||
|
if (!s) return ''
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a lucide category icon to an inline SVG string in the given colour.
|
||||||
|
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
|
||||||
|
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
|
try {
|
||||||
|
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
|
||||||
|
// into an <img src> — everything else is a fetch seed, not a displayable URL.
|
||||||
|
function isDisplayablePhoto(url: string | null | undefined): url is string {
|
||||||
|
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
|
||||||
|
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||||
|
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||||
|
|
||||||
|
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
|
||||||
|
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
|
||||||
|
const img = isDisplayablePhoto(photoUrl)
|
||||||
|
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
|
||||||
|
: ''
|
||||||
|
const category =
|
||||||
|
place.category_name && place.category_icon
|
||||||
|
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
|
||||||
|
: ''
|
||||||
|
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
|
||||||
|
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
|
||||||
|
export function buildPoiPopupHtml(poi: Poi): string {
|
||||||
|
const cat = POI_CATEGORY_BY_KEY[poi.category]
|
||||||
|
const color = cat?.color || '#6b7280'
|
||||||
|
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
|
||||||
|
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
|
||||||
|
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
|
||||||
|
return `${CARD_OPEN}${head}${address}</div>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||||
|
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||||
|
// lives there; label/icon/colour live here. `color` doubles as the active-pill
|
||||||
|
// fill AND the marker colour, so the pill and the map agree visually.
|
||||||
|
export interface PoiCategory {
|
||||||
|
key: string
|
||||||
|
labelKey: string
|
||||||
|
Icon: LucideIcon
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POI_CATEGORIES: PoiCategory[] = [
|
||||||
|
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||||
|
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||||
|
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||||
|
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||||
|
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||||
|
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||||
|
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||||
|
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||||
|
POI_CATEGORIES.map(c => [c.key, c]),
|
||||||
|
)
|
||||||
|
|
||||||
|
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||||
|
export interface Poi {
|
||||||
|
osm_id: string
|
||||||
|
name: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
category: string
|
||||||
|
poi_type: string
|
||||||
|
address: string | null
|
||||||
|
website: string | null
|
||||||
|
phone: string | null
|
||||||
|
opening_hours: string | null
|
||||||
|
cuisine: string | null
|
||||||
|
source: 'openstreetmap'
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import mapboxgl from 'mapbox-gl'
|
import type mapboxgl from 'mapbox-gl'
|
||||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||||
import { escapeHtml } from '@trek/shared'
|
import { escapeHtml } from '@trek/shared'
|
||||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||||
@@ -126,6 +126,7 @@ interface TransportItem {
|
|||||||
res: Reservation
|
res: Reservation
|
||||||
from: ReservationEndpoint
|
from: ReservationEndpoint
|
||||||
to: ReservationEndpoint
|
to: ReservationEndpoint
|
||||||
|
waypoints: ReservationEndpoint[]
|
||||||
type: TransportType
|
type: TransportType
|
||||||
arcs: [number, number][][]
|
arcs: [number, number][][]
|
||||||
primaryArc: [number, number][]
|
primaryArc: [number, number][]
|
||||||
@@ -137,23 +138,38 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
|||||||
const out: TransportItem[] = []
|
const out: TransportItem[] = []
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
const eps = r.endpoints || []
|
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
|
||||||
const from = eps.find(e => e.role === 'from')
|
const waypoints = (r.endpoints || [])
|
||||||
const to = eps.find(e => e.role === 'to')
|
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||||
if (!from || !to) continue
|
.slice()
|
||||||
|
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||||
|
if (waypoints.length < 2) continue
|
||||||
|
const from = waypoints[0]
|
||||||
|
const to = waypoints[waypoints.length - 1]
|
||||||
const type = r.type as TransportType
|
const type = r.type as TransportType
|
||||||
const isGeo = TYPE_META[type].geodesic
|
const isGeo = TYPE_META[type].geodesic
|
||||||
const arcs = isGeo
|
// One arc per leg (between consecutive waypoints), concatenated.
|
||||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
const arcs: [number, number][][] = []
|
||||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
let distanceKm = 0
|
||||||
|
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||||
|
const a = waypoints[i]
|
||||||
|
const b = waypoints[i + 1]
|
||||||
|
const segArcs = isGeo
|
||||||
|
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||||
|
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||||
|
arcs.push(...segArcs)
|
||||||
|
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||||
|
}
|
||||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
const primaryArc = arcs[primaryIdx] ?? []
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
const distance = `${Math.round(distanceKm)} km`
|
||||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
const mainLabel = waypoints.every(w => w.code)
|
||||||
|
? waypoints.map(w => w.code).join(' → ')
|
||||||
|
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
||||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||||
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
|
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -204,18 +220,29 @@ export interface ReservationOverlayOptions {
|
|||||||
onEndpointClick?: (reservationId: number) => void
|
onEndpointClick?: (reservationId: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GlMarker = {
|
||||||
|
setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
|
||||||
|
addTo: (map: mapboxgl.Map) => GlMarker
|
||||||
|
remove: () => void
|
||||||
|
getElement: () => HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
|
||||||
|
|
||||||
export class ReservationMapboxOverlay {
|
export class ReservationMapboxOverlay {
|
||||||
private map: mapboxgl.Map
|
private map: mapboxgl.Map
|
||||||
private items: TransportItem[] = []
|
private items: TransportItem[] = []
|
||||||
private opts: ReservationOverlayOptions
|
private opts: ReservationOverlayOptions
|
||||||
private endpointMarkers: mapboxgl.Marker[] = []
|
private MarkerCtor: MarkerConstructor
|
||||||
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
|
private endpointMarkers: GlMarker[] = []
|
||||||
|
private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
|
||||||
private rerender: () => void
|
private rerender: () => void
|
||||||
private destroyed = false
|
private destroyed = false
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
|
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) {
|
||||||
this.map = map
|
this.map = map
|
||||||
this.opts = opts
|
this.opts = opts
|
||||||
|
this.MarkerCtor = MarkerCtor
|
||||||
this.rerender = () => { if (!this.destroyed) this.render() }
|
this.rerender = () => { if (!this.destroyed) this.render() }
|
||||||
this.setupLayer()
|
this.setupLayer()
|
||||||
map.on('zoomend', this.rerender)
|
map.on('zoomend', this.rerender)
|
||||||
@@ -321,7 +348,7 @@ export class ReservationMapboxOverlay {
|
|||||||
if (show) {
|
if (show) {
|
||||||
for (const item of visibleItems) {
|
for (const item of visibleItems) {
|
||||||
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
||||||
for (const ep of [item.from, item.to]) {
|
for (const ep of item.waypoints) {
|
||||||
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.innerHTML = endpointMarkerHtml(item.type, label)
|
el.innerHTML = endpointMarkerHtml(item.type, label)
|
||||||
@@ -334,7 +361,7 @@ export class ReservationMapboxOverlay {
|
|||||||
this.opts.onEndpointClick?.(item.res.id)
|
this.opts.onEndpointClick?.(item.res.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
|
const marker = new this.MarkerCtor({ element: node, anchor: 'center' })
|
||||||
.setLngLat([ep.lng, ep.lat])
|
.setLngLat([ep.lng, ep.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
this.endpointMarkers.push(marker)
|
this.endpointMarkers.push(marker)
|
||||||
@@ -342,29 +369,10 @@ export class ReservationMapboxOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── stats label (flights only) ──────────────────────────────────
|
// Stats badge removed — the floating route/duration label on the arc is no
|
||||||
|
// longer drawn; only the connection line and the airport markers remain.
|
||||||
this.statsMarkers.forEach(s => s.marker.remove())
|
this.statsMarkers.forEach(s => s.marker.remove())
|
||||||
this.statsMarkers = []
|
this.statsMarkers = []
|
||||||
if (show && this.opts.showStats) {
|
|
||||||
for (const item of visibleItems) {
|
|
||||||
if (item.type !== 'flight') continue
|
|
||||||
if (!labelVisibleIds.has(item.res.id)) continue
|
|
||||||
if (!item.mainLabel && !item.subLabel) continue
|
|
||||||
const arc = item.primaryArc
|
|
||||||
if (arc.length < 2) continue
|
|
||||||
const mid = arc[Math.floor(arc.length / 2)]!
|
|
||||||
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
|
|
||||||
el.innerHTML = html
|
|
||||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
|
||||||
.setLngLat([mid[1], mid[0]])
|
|
||||||
.addTo(map)
|
|
||||||
this.statsMarkers.push({ marker, arc })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Prime rotation once so labels don't flash horizontal on first paint.
|
|
||||||
this.updateStatsRotation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { mapsApi } from '../../api/client'
|
||||||
|
import type { Poi } from './poiCategories'
|
||||||
|
|
||||||
|
export interface Bbox { south: number; west: number; north: number; east: number }
|
||||||
|
|
||||||
|
// A request we cancelled on purpose (newer search superseded it) — not a failure.
|
||||||
|
function isAbortError(err: unknown): boolean {
|
||||||
|
const e = err as { name?: string; code?: string } | null
|
||||||
|
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
||||||
|
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
||||||
|
* the results stale (`moved`) so the pill can offer "search this area". This keeps
|
||||||
|
* Overpass load (and visual churn) down.
|
||||||
|
*/
|
||||||
|
export function usePoiExplore() {
|
||||||
|
const [active, setActive] = useState<Set<string>>(() => new Set())
|
||||||
|
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||||
|
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
||||||
|
const [moved, setMoved] = useState(false)
|
||||||
|
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
|
||||||
|
// the pill can offer a retry instead of looking like "no places here".
|
||||||
|
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
const bboxRef = useRef<Bbox | null>(null)
|
||||||
|
// activeRef always mirrors the latest active set so async callbacks (fetch
|
||||||
|
// completions) can check whether a category is still wanted.
|
||||||
|
const activeRef = useRef(active)
|
||||||
|
activeRef.current = active
|
||||||
|
// One in-flight AbortController per category, so re-toggling / re-searching
|
||||||
|
// cancels the previous (possibly slow) Overpass request instead of racing it.
|
||||||
|
const abortRef = useRef<Record<string, AbortController>>({})
|
||||||
|
|
||||||
|
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (on) next.add(key); else next.delete(key)
|
||||||
|
return next
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
|
||||||
|
if (on === prev.has(key)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (on) next.add(key); else next.delete(key)
|
||||||
|
return next
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
||||||
|
abortRef.current[key]?.abort()
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abortRef.current[key] = ctrl
|
||||||
|
setLoading(key, true)
|
||||||
|
setError(key, false)
|
||||||
|
try {
|
||||||
|
const res = await mapsApi.pois(key, bbox, ctrl.signal)
|
||||||
|
// Drop the result if the user toggled this category off while the (slow)
|
||||||
|
// Overpass request was in flight — otherwise stale results re-appear.
|
||||||
|
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
||||||
|
} catch (err) {
|
||||||
|
// A superseded request was aborted on purpose — leave its state untouched
|
||||||
|
// so the newer request owns the spinner and results.
|
||||||
|
if (isAbortError(err)) return
|
||||||
|
// A real failure (every Overpass mirror down/timed out): surface it instead
|
||||||
|
// of a silent empty so the user can retry rather than assume "no places".
|
||||||
|
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
||||||
|
if (activeRef.current.has(key)) setError(key, true)
|
||||||
|
} finally {
|
||||||
|
// Only the latest controller for this key clears the spinner; a superseded
|
||||||
|
// one must not, or it would hide the newer request's in-flight state.
|
||||||
|
if (abortRef.current[key] === ctrl) {
|
||||||
|
setLoading(key, false)
|
||||||
|
delete abortRef.current[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setLoading, setError])
|
||||||
|
|
||||||
|
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||||
|
bboxRef.current = bbox
|
||||||
|
if (activeRef.current.size > 0) setMoved(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Single-select: clicking a category switches to it (dropping the previous one
|
||||||
|
// and its markers immediately) and fetches it for the current viewport; clicking
|
||||||
|
// the already-active category turns it off.
|
||||||
|
const toggle = useCallback((key: string) => {
|
||||||
|
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
||||||
|
setMoved(false)
|
||||||
|
setErrorKeys(new Set())
|
||||||
|
// Switching to another category (or turning off) — cancel any in-flight
|
||||||
|
// fetches so their results can't land after the selection changed.
|
||||||
|
Object.values(abortRef.current).forEach(c => c.abort())
|
||||||
|
abortRef.current = {}
|
||||||
|
if (isOnlyActive) {
|
||||||
|
setActive(new Set())
|
||||||
|
setByCat({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActive(new Set([key]))
|
||||||
|
setByCat({})
|
||||||
|
if (bboxRef.current) fetchCat(key, bboxRef.current)
|
||||||
|
}, [fetchCat])
|
||||||
|
|
||||||
|
const searchArea = useCallback(() => {
|
||||||
|
const bbox = bboxRef.current
|
||||||
|
if (!bbox) return
|
||||||
|
setMoved(false)
|
||||||
|
activeRef.current.forEach(key => fetchCat(key, bbox))
|
||||||
|
}, [fetchCat])
|
||||||
|
|
||||||
|
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||||
|
|
||||||
|
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
|
||||||
|
}
|
||||||
@@ -84,6 +84,22 @@ const transportReservation = {
|
|||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
|
const multiLegFlight = {
|
||||||
|
id: 401,
|
||||||
|
title: 'Flight to Tokyo',
|
||||||
|
type: 'flight',
|
||||||
|
day_id: 10,
|
||||||
|
reservation_time: '2025-06-01T08:00:00',
|
||||||
|
confirmation_number: 'XYZ789',
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
legs: [
|
||||||
|
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' },
|
||||||
|
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' },
|
||||||
|
],
|
||||||
|
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||||
|
}),
|
||||||
|
} as any
|
||||||
|
|
||||||
const richArgs = {
|
const richArgs = {
|
||||||
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
||||||
days: [dayWithPlaces],
|
days: [dayWithPlaces],
|
||||||
@@ -196,6 +212,16 @@ describe('downloadTripPDF', () => {
|
|||||||
const iframe = getIframe()
|
const iframe = getIframe()
|
||||||
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
||||||
expect(iframe!.srcdoc).toContain('ABC123')
|
expect(iframe!.srcdoc).toContain('ABC123')
|
||||||
|
// Single-leg flight keeps its full-route subtitle.
|
||||||
|
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
|
||||||
|
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
|
||||||
|
const iframe = getIframe()
|
||||||
|
// One subtitle line per leg, each with its own flight number and segment route.
|
||||||
|
expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER')
|
||||||
|
expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
||||||
@@ -297,6 +323,28 @@ describe('downloadTripPDF', () => {
|
|||||||
expect(photoCalled).toBe(true)
|
expect(photoCalled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
|
||||||
|
let fetchedId: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
|
||||||
|
fetchedId = params.placeId as string
|
||||||
|
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
// The assignment projection drops osm_id; the full place in `places` carries it.
|
||||||
|
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
|
||||||
|
const args = {
|
||||||
|
...richArgs,
|
||||||
|
places: [osmPlace],
|
||||||
|
assignments: {
|
||||||
|
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
|
||||||
|
} as any,
|
||||||
|
}
|
||||||
|
await downloadTripPDF(args)
|
||||||
|
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
|
||||||
|
expect(fetchedId).toBe('node/240109189')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
|
||||||
const args = {
|
const args = {
|
||||||
...minimalArgs,
|
...minimalArgs,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
|||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
import { splitReservationDateTime } from '../../utils/formatters'
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
import { getFlightLegs } from '../../utils/flightLegs'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -96,21 +97,29 @@ function dayCost(assignments, dayId, locale) {
|
|||||||
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-fetch Google Place photos for all assigned places
|
// Pre-fetch place photos for all assigned places.
|
||||||
async function fetchPlacePhotos(assignments: AssignmentsMap) {
|
// Assignment places are a server-side projection that drops osm_id, so we recover
|
||||||
|
// the full place from the trip's places pool and key the photo off the same id the
|
||||||
|
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
|
||||||
|
// places fell back to category icons in the PDF even though they show photos in-app.
|
||||||
|
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
|
||||||
const photoMap = {} // placeId → photoUrl
|
const photoMap = {} // placeId → photoUrl
|
||||||
|
// The assignment projection drops osm_id, so recover it from the full places pool.
|
||||||
|
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
|
||||||
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
|
||||||
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
|
||||||
|
|
||||||
// Assignment places are a server-side projection that omits osm_id, so photo
|
const toFetch = unique
|
||||||
// pre-fetch keys off the google_place_id that the projection does carry.
|
.map(p => ({ p, osm_id: osmById.get(p.id) }))
|
||||||
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
|
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
toFetch.map(async (place) => {
|
toFetch.map(async ({ p, osm_id }) => {
|
||||||
|
// Same key the app UI uses: google_place_id || osm_id || coords.
|
||||||
|
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
|
||||||
try {
|
try {
|
||||||
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
|
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
|
||||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
if (data.photoUrl) photoMap[p.id] = data.photoUrl
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -140,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
|
||||||
const accommodations = await accommodationsApi.list(trip.id);
|
const accommodations = await accommodationsApi.list(trip.id);
|
||||||
|
|
||||||
// Pre-fetch place photos from Google
|
// Pre-fetch place photos (Google, OSM and coords-only places)
|
||||||
const photoMap = await fetchPlacePhotos(assignments)
|
const photoMap = await fetchPlacePhotos(assignments, places)
|
||||||
|
|
||||||
const totalAssigned = new Set(
|
const totalAssigned = new Set(
|
||||||
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
|
||||||
@@ -215,11 +224,30 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const icon = reservationIconSvg(r.type)
|
const icon = reservationIconSvg(r.type)
|
||||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
// Flights render one subtitle line per leg (see below); everything else is a single line.
|
||||||
|
let subtitleLines: string[] = []
|
||||||
|
if (r.type === 'flight') {
|
||||||
|
const legs = getFlightLegs(r)
|
||||||
|
if (legs.length > 1) {
|
||||||
|
// Multi-leg: one line per leg so every flight number + segment route is shown.
|
||||||
|
subtitleLines = legs.map(l =>
|
||||||
|
[l.airline, l.flight_number,
|
||||||
|
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||||
|
.filter(Boolean).join(' · '))
|
||||||
|
.filter(Boolean)
|
||||||
|
} else {
|
||||||
|
// Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the
|
||||||
|
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||||
|
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||||
|
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||||
|
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
|
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const phase = pdfGetSpanPhase(r, day.id)
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
@@ -232,7 +260,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
${subtitleLines.filter(Boolean).map(s => `<div class="note-time">${escHtml(s)}</div>`).join('')}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -287,6 +315,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||||
|
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
|
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
|
||||||
|
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
|
||||||
|
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/trips/1/packing/99', () => {
|
http.delete('/api/trips/1/packing/99', () => {
|
||||||
@@ -1415,4 +1417,83 @@ describe('PackingListPanel', () => {
|
|||||||
expect(clickSpy).toHaveBeenCalled();
|
expect(clickSpy).toHaveBeenCalled();
|
||||||
clickSpy.mockRestore();
|
clickSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
|
||||||
|
// handleDeleteItem decides "last in category" from the rendered list.
|
||||||
|
seedStore(useTripStore, { packingItems: [item] });
|
||||||
|
let deleted = false;
|
||||||
|
let putBody: Record<string, unknown> | null = null;
|
||||||
|
server.use(
|
||||||
|
http.delete('/api/trips/1/packing/99', () => {
|
||||||
|
deleted = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/packing/99', async ({ request }) => {
|
||||||
|
putBody = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingListPanel tripId={1} items={[item]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
// The row is updated in place (same id) rather than deleted, so colour/position hold.
|
||||||
|
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
|
||||||
|
expect(deleted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
||||||
|
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||||
|
let deleted = false;
|
||||||
|
let converted = false;
|
||||||
|
server.use(
|
||||||
|
http.delete('/api/trips/1/packing/5', () => {
|
||||||
|
deleted = true;
|
||||||
|
return HttpResponse.json({ success: true });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/packing/5', () => {
|
||||||
|
converted = true;
|
||||||
|
return HttpResponse.json({ item: placeholder });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByTitle('Delete'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleted).toBe(true));
|
||||||
|
// It is the placeholder itself — it must be removed, not re-converted.
|
||||||
|
expect(converted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
|
||||||
|
seedStore(useTripStore, { packingItems: [placeholder] });
|
||||||
|
let posted = false;
|
||||||
|
let putBody: Record<string, unknown> | null = null;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/packing', () => {
|
||||||
|
posted = true;
|
||||||
|
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
|
||||||
|
}),
|
||||||
|
http.put('/api/trips/1/packing/5', async ({ request }) => {
|
||||||
|
putBody = await request.json() as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
render(<PackingListPanel tripId={1} items={[placeholder]} />);
|
||||||
|
|
||||||
|
// Open the category's inline "Add item" and add a real entry.
|
||||||
|
await user.click(screen.getByText('Add item'));
|
||||||
|
const input = await screen.findByPlaceholderText('Item name...');
|
||||||
|
await user.type(input, 'Tent');
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
|
||||||
|
expect(posted).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface KategorieGruppeProps {
|
|||||||
allCategories: string[]
|
allCategories: string[]
|
||||||
onRename: (oldName: string, newName: string) => Promise<void>
|
onRename: (oldName: string, newName: string) => Promise<void>
|
||||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||||
|
onDeleteItem: (item: PackingItem) => Promise<void>
|
||||||
onAddItem: (category: string, name: string) => Promise<void>
|
onAddItem: (category: string, name: string) => Promise<void>
|
||||||
assignees: CategoryAssignee[]
|
assignees: CategoryAssignee[]
|
||||||
tripMembers: TripMember[]
|
tripMembers: TripMember[]
|
||||||
@@ -28,7 +29,7 @@ interface KategorieGruppeProps {
|
|||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
|
||||||
const [offen, setOffen] = useState(true)
|
const [offen, setOffen] = useState(true)
|
||||||
const [editingName, setEditingName] = useState(false)
|
const [editingName, setEditingName] = useState(false)
|
||||||
const [editKatName, setEditKatName] = useState(kategorie)
|
const [editKatName, setEditKatName] = useState(kategorie)
|
||||||
@@ -231,7 +232,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
{offen && (
|
{offen && (
|
||||||
<div style={{ padding: '4px 4px 6px' }}>
|
<div style={{ padding: '4px 4px 6px' }}>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
|
||||||
))}
|
))}
|
||||||
{/* Inline add item */}
|
{/* Inline add item */}
|
||||||
{canEdit && (showAddItem ? (
|
{canEdit && (showAddItem ? (
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ interface ArtikelZeileProps {
|
|||||||
tripId: number
|
tripId: number
|
||||||
categories: string[]
|
categories: string[]
|
||||||
onCategoryChange: () => void
|
onCategoryChange: () => void
|
||||||
|
onDelete?: (item: PackingItem) => Promise<void>
|
||||||
bagTrackingEnabled?: boolean
|
bagTrackingEnabled?: boolean
|
||||||
bags?: PackingBag[]
|
bags?: PackingBag[]
|
||||||
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
onCreateBag: (name: string) => Promise<PackingBag | undefined>
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
|
||||||
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
|
||||||
@@ -43,6 +44,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
// The panel routes deletion through onDelete so an emptied custom category
|
||||||
|
// keeps its placeholder; fall back to a plain delete when used standalone.
|
||||||
|
if (onDelete) { await onDelete(item); return }
|
||||||
try { await deletePackingItem(tripId, item.id) }
|
try { await deletePackingItem(tripId, item.id) }
|
||||||
catch { toast.error(t('packing.toast.deleteError')) }
|
catch { toast.error(t('packing.toast.deleteError')) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
|||||||
|
|
||||||
export function PackingList(S: PackingState) {
|
export function PackingList(S: PackingState) {
|
||||||
const {
|
const {
|
||||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
|
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
|
||||||
} = S
|
} = S
|
||||||
@@ -31,6 +31,7 @@ export function PackingList(S: PackingState) {
|
|||||||
allCategories={allCategories}
|
allCategories={allCategories}
|
||||||
onRename={handleRenameCategory}
|
onRename={handleRenameCategory}
|
||||||
onDeleteAll={handleDeleteCategory}
|
onDeleteAll={handleDeleteCategory}
|
||||||
|
onDeleteItem={handleDeleteItem}
|
||||||
onAddItem={handleAddItemToCategory}
|
onAddItem={handleAddItemToCategory}
|
||||||
assignees={categoryAssignees[kat] || []}
|
assignees={categoryAssignees[kat] || []}
|
||||||
tripMembers={tripMembers}
|
tripMembers={tripMembers}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
|
|||||||
'#14b8a6', // teal
|
'#14b8a6', // teal
|
||||||
]
|
]
|
||||||
|
|
||||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
|
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
||||||
|
|
||||||
// A category's first item is seeded with this sentinel because the server
|
// A category's first item is seeded with this sentinel because the server
|
||||||
// rejects empty names. Treat it as a placeholder in the UI.
|
// rejects empty names. Treat it as a placeholder in the UI.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { packingApi, tripsApi } from '../../api/client'
|
import { packingApi, tripsApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import type { PackingItem, PackingBag } from '../../types'
|
import type { PackingItem, PackingBag } from '../../types'
|
||||||
import { BAG_COLORS } from './packingListPanel.constants'
|
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||||
import { parseImportLines } from './packingListPanel.helpers'
|
import { parseImportLines } from './packingListPanel.helpers'
|
||||||
|
|
||||||
export interface TripMember {
|
export interface TripMember {
|
||||||
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('packing_edit', trip)
|
const canEdit = can('packing_edit', trip)
|
||||||
@@ -106,10 +106,45 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
|
|
||||||
const handleAddItemToCategory = async (category: string, name: string) => {
|
const handleAddItemToCategory = async (category: string, name: string) => {
|
||||||
try {
|
try {
|
||||||
await addPackingItem(tripId, { name, category })
|
// Reuse the '...' placeholder slot when the category already has one, so a
|
||||||
|
// freshly-emptied category keeps its position (and therefore its colour)
|
||||||
|
// instead of the new item being appended to the end of the list.
|
||||||
|
const placeholder = useTripStore.getState().packingItems.find(
|
||||||
|
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
|
||||||
|
)
|
||||||
|
if (placeholder) {
|
||||||
|
await updatePackingItem(tripId, placeholder.id, { name })
|
||||||
|
} else {
|
||||||
|
await addPackingItem(tripId, { name, category })
|
||||||
|
}
|
||||||
} catch { toast.error(t('packing.toast.addError')) }
|
} catch { toast.error(t('packing.toast.addError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleting an item from a row. When it is the last item of a user-created
|
||||||
|
// category, turn that row back into the '...' placeholder in place rather than
|
||||||
|
// deleting it (#1289). Updating the row keeps its id, list position and colour,
|
||||||
|
// so the category neither disappears nor jumps to the end. The default
|
||||||
|
// (uncategorized) group and the placeholder row itself are deleted normally —
|
||||||
|
// removing the placeholder is how an empty category is dismissed.
|
||||||
|
const handleDeleteItem = async (item: PackingItem) => {
|
||||||
|
const category = item.category
|
||||||
|
const isLastInCategory = !!category
|
||||||
|
&& item.name !== PACKING_PLACEHOLDER_NAME
|
||||||
|
&& !items.some(i => i.id !== item.id && i.category === category)
|
||||||
|
try {
|
||||||
|
if (isLastInCategory) {
|
||||||
|
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||||
|
await updatePackingItem(tripId, item.id, {
|
||||||
|
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await deletePackingItem(tripId, item.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('packing.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddNewCategory = async () => {
|
const handleAddNewCategory = async () => {
|
||||||
if (!newCatName.trim()) return
|
if (!newCatName.trim()) return
|
||||||
let catName = newCatName.trim()
|
let catName = newCatName.trim()
|
||||||
@@ -308,7 +343,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
||||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||||
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||||
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
|
import { Plane, X, Check } from 'lucide-react'
|
||||||
|
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { airtrailApi, reservationsApi } from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
|
interface AirTrailImportModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
tripId: number
|
||||||
|
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
|
||||||
|
function fmtDate(d: string | null, locale: string): string {
|
||||||
|
if (!d) return ''
|
||||||
|
try {
|
||||||
|
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const trip = useTripStore(s => s.trip)
|
||||||
|
const reservations = useTripStore(s => s.reservations)
|
||||||
|
const loadReservations = useTripStore(s => s.loadReservations)
|
||||||
|
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [flights, setFlights] = useState<AirtrailFlight[]>([])
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
// AirTrail flight ids already linked to a reservation in this trip.
|
||||||
|
const importedIds = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of reservations) {
|
||||||
|
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}, [reservations])
|
||||||
|
|
||||||
|
const inRange = (f: AirtrailFlight): boolean =>
|
||||||
|
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setError('')
|
||||||
|
setSelected(new Set())
|
||||||
|
setLoading(true)
|
||||||
|
airtrailApi
|
||||||
|
.flights()
|
||||||
|
.then((d: { flights: AirtrailFlight[] }) => {
|
||||||
|
const list = d.flights ?? []
|
||||||
|
setFlights(list)
|
||||||
|
// Pre-select the flights that fall inside the trip and aren't imported yet.
|
||||||
|
const pre = new Set<string>()
|
||||||
|
for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id)
|
||||||
|
setSelected(pre)
|
||||||
|
})
|
||||||
|
.catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError')))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const { during, others } = useMemo(() => {
|
||||||
|
const during: AirtrailFlight[] = []
|
||||||
|
const others: AirtrailFlight[] = []
|
||||||
|
for (const f of flights) (inRange(f) ? during : others).push(f)
|
||||||
|
const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '')
|
||||||
|
return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [flights, trip?.start_date, trip?.end_date])
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { onClose() }
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const ids = [...selected].filter(id => !importedIds.has(id))
|
||||||
|
if (ids.length === 0 || importing) return
|
||||||
|
setImporting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
|
||||||
|
await loadReservations(tripId)
|
||||||
|
|
||||||
|
const imported = result.imported ?? []
|
||||||
|
if (imported.length > 0) {
|
||||||
|
pushUndo?.(t('reservations.airtrail.undo'), async () => {
|
||||||
|
const linked = useTripStore.getState().reservations.filter(
|
||||||
|
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
|
||||||
|
)
|
||||||
|
await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
|
||||||
|
await loadReservations(tripId)
|
||||||
|
})
|
||||||
|
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length
|
||||||
|
if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip }))
|
||||||
|
if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported'))
|
||||||
|
|
||||||
|
handleClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error ?? t('reservations.airtrail.importError'))
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectableCount = [...selected].filter(id => !importedIds.has(id)).length
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const renderFlight = (f: AirtrailFlight) => {
|
||||||
|
const already = importedIds.has(f.id)
|
||||||
|
const isSelected = selected.has(f.id)
|
||||||
|
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'} → ${f.toCode ?? '?'}`
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => !already && toggle(f.id)}
|
||||||
|
disabled={already}
|
||||||
|
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
|
||||||
|
style={{
|
||||||
|
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||||
|
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||||
|
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
|
||||||
|
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
|
||||||
|
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||||
|
background: isSelected || already ? 'var(--accent)' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{already && (
|
||||||
|
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
|
||||||
|
{t('reservations.airtrail.alreadyImported')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
className="bg-[rgba(0,0,0,0.4)]"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
|
onClick={e => {
|
||||||
|
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||||
|
mouseDownTarget.current = null
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="bg-surface-card"
|
||||||
|
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
|
<Plane size={16} color="#3b82f6" />
|
||||||
|
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('reservations.airtrail.title')}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && flights.length === 0 && !error && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
{t('reservations.airtrail.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && during.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
|
||||||
|
{t('reservations.airtrail.duringTrip')}
|
||||||
|
</div>
|
||||||
|
{during.map(renderFlight)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && others.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
|
||||||
|
{t('reservations.airtrail.otherFlights')}
|
||||||
|
</div>
|
||||||
|
{others.map(renderFlight)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={selectableCount === 0 || importing}
|
||||||
|
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||||
|
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { formatMoney } from '../../utils/formatters'
|
||||||
|
import { catMeta } from '../Budget/costsCategories'
|
||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
||||||
|
* category fields: when no expense is linked yet it offers a "create expense"
|
||||||
|
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||||
|
* once linked it shows the expense with edit / remove actions.
|
||||||
|
*/
|
||||||
|
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
|
||||||
|
reservationId: number | null
|
||||||
|
onCreate: () => void
|
||||||
|
onEdit: (item: BudgetItem) => void
|
||||||
|
onRemove: (item: BudgetItem) => void
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const trip = useTripStore(s => s.trip)
|
||||||
|
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||||
|
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||||
|
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
||||||
|
|
||||||
|
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||||
|
|
||||||
|
if (linked) {
|
||||||
|
const meta = catMeta(linked.category)
|
||||||
|
const Icon = meta.Icon
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||||
|
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||||
|
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||||
|
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||||
|
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
||||||
|
<button type="button" onClick={onCreate}
|
||||||
|
className="bg-surface-secondary border border-edge text-content"
|
||||||
|
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={15} /> {t('reservations.createExpense')}
|
||||||
|
</button>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { BudgetItem } from '../../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A request from a booking modal to open the Costs expense editor — either to
|
||||||
|
* edit the already-linked expense, or to create a new one prefilled from the
|
||||||
|
* booking (the modal saves the booking first so `reservationId` is known).
|
||||||
|
*/
|
||||||
|
export interface BookingExpenseRequest {
|
||||||
|
editItem?: BudgetItem
|
||||||
|
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||||
|
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||||
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
|
|||||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||||
{ id: 'Bookmark', Icon: Bookmark },
|
{ id: 'Bookmark', Icon: Bookmark },
|
||||||
|
{ id: 'Utensils', Icon: Utensils },
|
||||||
|
{ id: 'Wine', Icon: Wine },
|
||||||
|
{ id: 'ParkingSquare', Icon: ParkingSquare },
|
||||||
|
{ id: 'Fuel', Icon: Fuel },
|
||||||
|
{ id: 'Footprints', Icon: Footprints },
|
||||||
|
{ id: 'Mountain', Icon: Mountain },
|
||||||
|
{ id: 'Waves', Icon: Waves },
|
||||||
|
{ id: 'Sun', Icon: Sun },
|
||||||
|
{ id: 'Umbrella', Icon: Umbrella },
|
||||||
|
{ id: 'Music', Icon: Music },
|
||||||
|
{ id: 'Landmark', Icon: Landmark },
|
||||||
|
{ id: 'Gift', Icon: Gift },
|
||||||
]
|
]
|
||||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
||||||
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||||
|
|||||||
@@ -168,6 +168,34 @@ describe('DayPlanSidebar', () => {
|
|||||||
expect(screen.getByText('D2')).toBeInTheDocument()
|
expect(screen.getByText('D2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── #1330: route tools for a single optimizable place ───────────────────────
|
||||||
|
it('FE-PLANNER-DAYPLAN-005b: route tools show for one located place with a bookend hotel (#1330)', () => {
|
||||||
|
const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
|
const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
|
||||||
|
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||||
|
const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.85, place_lng: 2.35 }]
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps({
|
||||||
|
days: [day, day2], places: [place], assignments: { '10': [assignment] },
|
||||||
|
accommodations: accommodations as any, selectedDayId: 10,
|
||||||
|
})} />)
|
||||||
|
// With accommodation optimization on, one located place is routable (hotel → place → hotel),
|
||||||
|
// so the route tools (here the Google Maps export button) must be visible.
|
||||||
|
expect(screen.getByRole('button', { name: 'Open in Google Maps' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYPLAN-005c: route tools stay hidden for one place with no bookend hotel (#1330 guard)', () => {
|
||||||
|
const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
|
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps({
|
||||||
|
days: [day], places: [place], assignments: { '10': [assignment] },
|
||||||
|
accommodations: [], selectedDayId: 10,
|
||||||
|
})} />)
|
||||||
|
// No accommodation to bookend the lone place, so nothing routable — tools stay hidden.
|
||||||
|
expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Day expansion/collapse ──────────────────────────────────────────────
|
// ── Day expansion/collapse ──────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => {
|
it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => {
|
||||||
@@ -1708,4 +1736,49 @@ describe('DayPlanSidebar', () => {
|
|||||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||||
expect(onEditReservation).not.toHaveBeenCalled()
|
expect(onEditReservation).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||||
|
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||||
|
]
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
|
const assigns = {
|
||||||
|
'10': [
|
||||||
|
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||||
|
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps({
|
||||||
|
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
|
||||||
|
})} />)
|
||||||
|
// Days are expanded by default, so route tools must be visible even with no selected day
|
||||||
|
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onReorder = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||||
|
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||||
|
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
|
||||||
|
]
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
|
const assigns = {
|
||||||
|
'10': [
|
||||||
|
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||||
|
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||||
|
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps({
|
||||||
|
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
|
||||||
|
})} />)
|
||||||
|
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
||||||
|
await user.click(optimizeBtn)
|
||||||
|
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
@@ -18,16 +18,16 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder'
|
||||||
import {
|
import {
|
||||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
type MergedItem,
|
type MergedItem,
|
||||||
} from '../../utils/dayMerge'
|
} from '../../utils/dayMerge'
|
||||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||||
@@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
|
|||||||
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
|
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
|
||||||
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
|
||||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
interface DayPlanSidebarProps {
|
interface DayPlanSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -51,6 +52,8 @@ interface DayPlanSidebarProps {
|
|||||||
onDayDetail: (day: Day) => void
|
onDayDetail: (day: Day) => void
|
||||||
accommodations?: Accommodation[]
|
accommodations?: Accommodation[]
|
||||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||||
|
onReorderDays?: (orderedIds: number[]) => void
|
||||||
|
onAddDay?: (position?: number) => void
|
||||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||||
onRouteCalculated: (route: RouteResult | null) => void
|
onRouteCalculated: (route: RouteResult | null) => void
|
||||||
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
||||||
@@ -82,6 +85,8 @@ interface DayPlanSidebarProps {
|
|||||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||||
initialScrollTop?: number
|
initialScrollTop?: number
|
||||||
onScrollTopChange?: (top: number) => void
|
onScrollTopChange?: (top: number) => void
|
||||||
|
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||||
|
showRouteToolsWhenExpanded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +101,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated,
|
||||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||||
reservations = [],
|
reservations = [],
|
||||||
visibleConnectionIds = [],
|
visibleConnectionIds = [],
|
||||||
@@ -123,6 +128,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
|
showRouteToolsWhenExpanded = false,
|
||||||
} = props
|
} = props
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
@@ -147,6 +153,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||||
|
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
||||||
|
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||||
|
// Recompute the hotel/route legs when the user flips km↔mi so the connector
|
||||||
|
// distances refresh instead of showing stale cached text (#1300).
|
||||||
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||||
const legsAbortRef = useRef<AbortController | null>(null)
|
const legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
@@ -171,7 +182,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [timeConfirm, setTimeConfirm] = useState<{
|
const [timeConfirm, setTimeConfirm] = useState<{
|
||||||
dayId: number; fromId: number; time: string;
|
dayId: number; fromId: number; time: string;
|
||||||
// For drag & drop reorder
|
// For drag & drop reorder
|
||||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
||||||
// For arrow reorder
|
// For arrow reorder
|
||||||
reorderIds?: number[];
|
reorderIds?: number[];
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -374,7 +385,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||||
const merged = mergedItemsMap[selectedDayId] || []
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||||
let cur: { id: number; lat: number; lng: number }[] = []
|
let cur: { id: number; lat: number; lng: number }[] = []
|
||||||
@@ -382,12 +393,54 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
} else if (it.type === 'transport') {
|
} else if (it.type === 'transport') {
|
||||||
if (cur.length >= 2) runs.push(cur)
|
const r = it.data
|
||||||
cur = []
|
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||||
|
if (from || to) {
|
||||||
|
// Located transport: route to its departure point, break the run (the
|
||||||
|
// flight/train itself isn't driven), and let its arrival start the next.
|
||||||
|
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
cur = []
|
||||||
|
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
|
||||||
|
} else if (cur.length > 0) {
|
||||||
|
// No location: ignore for routing, but attribute the through-leg to the
|
||||||
|
// booking so its distance/duration shows under it (purely cosmetic).
|
||||||
|
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cur.length >= 2) runs.push(cur)
|
if (cur.length >= 2) runs.push(cur)
|
||||||
if (runs.length === 0) { setRouteLegs({}); return }
|
|
||||||
|
// Hotel bookend legs: the drive from the day's accommodation to the first located
|
||||||
|
// waypoint of the day (morning) and from the last one back to it (evening). Only when
|
||||||
|
// the "optimize from accommodation" setting is on and the day has a hotel.
|
||||||
|
const day = days.find(d => d.id === selectedDayId)
|
||||||
|
const bookends = day && optimizeFromAccommodation !== false
|
||||||
|
? getDayBookendHotels(day, days, accommodations)
|
||||||
|
: null
|
||||||
|
const startHotel = bookends?.morning
|
||||||
|
const endHotel = bookends?.evening
|
||||||
|
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||||
|
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
|
||||||
|
// legs connect even when the day starts or ends with a booking rather than a place. Track
|
||||||
|
// whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1
|
||||||
|
// arrival the check-in hotel never drove to the departure airport (#1321).
|
||||||
|
const wayPts: { lat: number; lng: number; isPlace: boolean }[] = []
|
||||||
|
for (const it of merged) {
|
||||||
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
|
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true })
|
||||||
|
} else if (it.type === 'transport') {
|
||||||
|
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
|
||||||
|
if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false })
|
||||||
|
if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstWay = wayPts[0]
|
||||||
|
const lastWay = wayPts[wayPts.length - 1]
|
||||||
|
const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
|
||||||
|
const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
|
||||||
|
|
||||||
|
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
legsAbortRef.current = controller
|
legsAbortRef.current = controller
|
||||||
@@ -401,9 +454,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (err instanceof Error && err.name === 'AbortError') return
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!controller.signal.aborted) setRouteLegs(map)
|
|
||||||
|
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
||||||
|
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||||
|
return r.legs[0]
|
||||||
|
} catch { return undefined }
|
||||||
|
}
|
||||||
|
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
||||||
|
if (wantTop) {
|
||||||
|
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng })
|
||||||
|
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
||||||
|
}
|
||||||
|
if (wantBottom) {
|
||||||
|
const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
||||||
|
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
||||||
})()
|
})()
|
||||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -471,6 +542,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const assignmentIds: number[] = []
|
const assignmentIds: number[] = []
|
||||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||||
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||||
|
// Multi-leg flight legs share a reservation id, so their positions can't live in
|
||||||
|
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
|
||||||
|
const legPosUpdates: Record<number, Record<number, number>> = {}
|
||||||
|
|
||||||
let placeCount = 0
|
let placeCount = 0
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -491,7 +565,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
group.forEach((g, idx) => {
|
group.forEach((g, idx) => {
|
||||||
const pos = base + (idx + 1) / (group.length + 1)
|
const pos = base + (idx + 1) / (group.length + 1)
|
||||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||||
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
else if (g.type === 'transport') {
|
||||||
|
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
|
||||||
|
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,6 +587,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
}))
|
}))
|
||||||
setTransportPosVersion(v => v + 1)
|
setTransportPosVersion(v => v + 1)
|
||||||
}
|
}
|
||||||
|
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
|
||||||
|
// (the single per-booking slot can't hold one position per leg).
|
||||||
|
const legResIds = Object.keys(legPosUpdates)
|
||||||
|
if (legResIds.length) {
|
||||||
|
for (const ridStr of legResIds) {
|
||||||
|
const rid = Number(ridStr)
|
||||||
|
const r = useTripStore.getState().reservations.find(x => x.id === rid)
|
||||||
|
if (!r) continue
|
||||||
|
let parsed: any = {}
|
||||||
|
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
|
||||||
|
if (!Array.isArray(parsed.legs)) continue
|
||||||
|
const legs = parsed.legs.map((leg: any, i: number) => {
|
||||||
|
const pos = legPosUpdates[rid][i]
|
||||||
|
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
|
||||||
|
})
|
||||||
|
// Send metadata as an OBJECT (like the form does) — passing a JSON string
|
||||||
|
// here double-encodes it on the server, which wipes metadata.legs on read
|
||||||
|
// and collapses the flight back to a single span.
|
||||||
|
const newMeta = { ...parsed, legs }
|
||||||
|
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
|
||||||
|
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
|
||||||
|
}
|
||||||
|
setTransportPosVersion(v => v + 1)
|
||||||
|
}
|
||||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||||
if (transportUpdates.length) {
|
if (transportUpdates.length) {
|
||||||
onRouteRefresh?.()
|
onRouteRefresh?.()
|
||||||
@@ -528,8 +629,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
|
||||||
const m = getMergedItems(dayId)
|
const m = getMergedItems(dayId)
|
||||||
|
// Multi-leg flights expose one item per leg sharing the same reservation id;
|
||||||
|
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
|
||||||
|
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||||
|
|
||||||
// Check if a timed place is being moved → would it break chronological order?
|
// Check if a timed place is being moved → would it break chronological order?
|
||||||
if (fromType === 'place') {
|
if (fromType === 'place') {
|
||||||
@@ -537,11 +641,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||||
if (fromItem && fromMinutes !== null) {
|
if (fromItem && fromMinutes !== null) {
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(matchTo)
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
const simulated = [...m]
|
const simulated = [...m]
|
||||||
const [moved] = simulated.splice(fromIdx, 1)
|
const [moved] = simulated.splice(fromIdx, 1)
|
||||||
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
let insertIdx = simulated.findIndex(matchTo)
|
||||||
if (insertIdx === -1) insertIdx = simulated.length
|
if (insertIdx === -1) insertIdx = simulated.length
|
||||||
if (insertAfter) insertIdx += 1
|
if (insertAfter) insertIdx += 1
|
||||||
simulated.splice(insertIdx, 0, moved)
|
simulated.splice(insertIdx, 0, moved)
|
||||||
@@ -558,7 +662,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!isChronological) {
|
if (!isChronological) {
|
||||||
const placeTime = fromItem.data.place.place_time
|
const placeTime = fromItem.data.place.place_time
|
||||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr })
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -568,7 +672,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Build new order: remove the dragged item, insert at target position
|
// Build new order: remove the dragged item, insert at target position
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(matchTo)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
@@ -576,7 +680,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
let adjustedTo = newOrder.findIndex(matchTo)
|
||||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -590,7 +694,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const confirmTimeRemoval = async () => {
|
const confirmTimeRemoval = async () => {
|
||||||
if (!timeConfirm) return
|
if (!timeConfirm) return
|
||||||
const saved = { ...timeConfirm }
|
const saved = { ...timeConfirm }
|
||||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||||
setTimeConfirm(null)
|
setTimeConfirm(null)
|
||||||
|
|
||||||
// Remove time from assignment
|
// Remove time from assignment
|
||||||
@@ -633,13 +737,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Drag & drop reorder
|
// Drag & drop reorder
|
||||||
if (fromType && toType) {
|
if (fromType && toType) {
|
||||||
|
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
const toIdx = m.findIndex(matchTo)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
let adjustedTo = newOrder.findIndex(matchTo)
|
||||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -690,18 +795,20 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
||||||
if (!selectedDayId) return
|
if (!dayId) return
|
||||||
const da = getDayAssignments(selectedDayId)
|
const da = getDayAssignments(dayId)
|
||||||
if (da.length < 3) return
|
if (da.length < 3) return
|
||||||
|
|
||||||
const prevIds = da.map(a => a.id)
|
const prevIds = da.map(a => a.id)
|
||||||
|
|
||||||
// Separate locked (stay at their index) and unlocked assignments
|
// Separate fixed (stay at their index) and movable assignments. A place is
|
||||||
|
// fixed if it's locked OR has a set time — timed places are anchored by their
|
||||||
|
// time, so the optimizer must not reshuffle them.
|
||||||
const locked = new Map() // index -> assignment
|
const locked = new Map() // index -> assignment
|
||||||
const unlocked = []
|
const unlocked = []
|
||||||
da.forEach((a, i) => {
|
da.forEach((a, i) => {
|
||||||
if (lockedIds.has(a.id)) locked.set(i, a)
|
if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a)
|
||||||
else unlocked.push(a)
|
else unlocked.push(a)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -710,7 +817,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
||||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||||
const day = days.find(d => d.id === selectedDayId)
|
const day = days.find(d => d.id === dayId)
|
||||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||||
? getAccommodationAnchors(day, days, accommodations)
|
? getAccommodationAnchors(day, days, accommodations)
|
||||||
: {}
|
: {}
|
||||||
@@ -727,10 +834,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||||
}
|
}
|
||||||
|
|
||||||
await onReorder(selectedDayId, result.map(a => a.id))
|
await onReorder(dayId, result.map(a => a.id))
|
||||||
const usedHotel = !!(anchors.start || anchors.end)
|
const usedHotel = !!(anchors.start || anchors.end)
|
||||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
||||||
const capturedDayId = selectedDayId
|
const capturedDayId = dayId
|
||||||
pushUndo?.(t('undo.optimize'), async () => {
|
pushUndo?.(t('undo.optimize'), async () => {
|
||||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||||
})
|
})
|
||||||
@@ -814,6 +921,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onDayDetail,
|
onDayDetail,
|
||||||
accommodations,
|
accommodations,
|
||||||
onReorder,
|
onReorder,
|
||||||
|
onReorderDays,
|
||||||
|
onAddDay,
|
||||||
onUpdateDayTitle,
|
onUpdateDayTitle,
|
||||||
onRouteCalculated,
|
onRouteCalculated,
|
||||||
onAssignToDay,
|
onAssignToDay,
|
||||||
@@ -845,6 +954,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
|
showRouteToolsWhenExpanded,
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
language,
|
language,
|
||||||
@@ -878,6 +988,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
|
hotelLegs,
|
||||||
|
setHotelLegs,
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -943,6 +1055,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
|
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
|
||||||
const S = useDayPlanSidebar(props)
|
const S = useDayPlanSidebar(props)
|
||||||
|
// Needed by the route-tools visibility gate in the render below (#1330); the hook
|
||||||
|
// keeps its own copy, so read it reactively here in the component scope too.
|
||||||
|
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||||
const {
|
const {
|
||||||
tripId,
|
tripId,
|
||||||
trip,
|
trip,
|
||||||
@@ -958,6 +1073,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
onDayDetail,
|
onDayDetail,
|
||||||
accommodations,
|
accommodations,
|
||||||
onReorder,
|
onReorder,
|
||||||
|
onReorderDays,
|
||||||
|
onAddDay,
|
||||||
onUpdateDayTitle,
|
onUpdateDayTitle,
|
||||||
onRouteCalculated,
|
onRouteCalculated,
|
||||||
onAssignToDay,
|
onAssignToDay,
|
||||||
@@ -989,6 +1106,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
|
showRouteToolsWhenExpanded,
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
language,
|
language,
|
||||||
@@ -1022,6 +1140,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
|
hotelLegs,
|
||||||
|
setHotelLegs,
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -1109,6 +1229,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
undoHover={undoHover}
|
undoHover={undoHover}
|
||||||
setUndoHover={setUndoHover}
|
setUndoHover={setUndoHover}
|
||||||
lastActionLabel={lastActionLabel}
|
lastActionLabel={lastActionLabel}
|
||||||
|
canEditDays={canEditDays}
|
||||||
|
onReorderDays={onReorderDays}
|
||||||
|
onAddDay={onAddDay}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tagesliste */}
|
{/* Tagesliste */}
|
||||||
@@ -1120,6 +1243,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const cost = dayTotalCost(day.id, assignments, currency)
|
const cost = dayTotalCost(day.id, assignments, currency)
|
||||||
const formattedDate = formatDate(day.date, locale)
|
const formattedDate = formatDate(day.date, locale)
|
||||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
||||||
|
// Route tools normally need 2+ stops, but a single located place is still
|
||||||
|
// routable when accommodation optimization can bookend it with a hotel
|
||||||
|
// (hotel → place → hotel, the same line the map draws) — otherwise the tools
|
||||||
|
// vanish on such a day (#1330). Purely additive to the 2+ case.
|
||||||
|
const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
|
||||||
|
const hasRouteBookend = !!(
|
||||||
|
(routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
|
||||||
|
(routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
|
||||||
|
)
|
||||||
|
const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend)
|
||||||
const isDragTarget = dragOverDayId === day.id
|
const isDragTarget = dragOverDayId === day.id
|
||||||
const merged = mergedItemsMap[day.id] || []
|
const merged = mergedItemsMap[day.id] || []
|
||||||
const dayNoteUi = noteUi[day.id]
|
const dayNoteUi = noteUi[day.id]
|
||||||
@@ -1311,6 +1444,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||||
const transportId = Number(parts[0])
|
const transportId = Number(parts[0])
|
||||||
|
const legPart = parts.find(p => /^leg\d+$/.test(p))
|
||||||
|
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
|
||||||
|
|
||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), day.id)
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
@@ -1318,15 +1453,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
} else if (fromReservationId) {
|
} else if (fromReservationId) {
|
||||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
|
||||||
} else if (assignmentId && fromDayId !== day.id) {
|
} else if (assignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (assignmentId) {
|
} else if (assignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex)
|
||||||
}
|
}
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
return
|
return
|
||||||
@@ -1359,6 +1494,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{isSelected && hotelLegs.top && (
|
||||||
|
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||||
|
)}
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
<div
|
<div
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||||
@@ -1372,9 +1510,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
merged.map((item, idx) => {
|
merged.map((item, idx) => {
|
||||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
|
||||||
|
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
|
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}`
|
||||||
|
|
||||||
if (item.type === 'place') {
|
if (item.type === 'place') {
|
||||||
const assignment = item.data
|
const assignment = item.data
|
||||||
@@ -1478,14 +1617,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
}}
|
}}
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
onContextMenu={e => {
|
||||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
||||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
ctxMenu.open(e, [
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||||
{ divider: true },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||||
])}
|
{ divider: true },
|
||||||
|
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||||
|
])
|
||||||
|
}}
|
||||||
onMouseEnter={e => {
|
onMouseEnter={e => {
|
||||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||||
e.currentTarget.style.background = 'var(--bg-hover)'
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
@@ -1722,7 +1864,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
|
|
||||||
// Subtitle aus Metadaten zusammensetzen
|
// Subtitle aus Metadaten zusammensetzen
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
if (res.type === 'flight') {
|
if (res.__leg) {
|
||||||
|
// One leg of a multi-leg flight — show this segment's own route.
|
||||||
|
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||||
|
if (res.__leg.from || res.__leg.to)
|
||||||
|
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||||
|
subtitle = parts.join(' · ')
|
||||||
|
} else if (res.type === 'flight') {
|
||||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||||
if (meta.departure_airport || meta.arrival_airport)
|
if (meta.departure_airport || meta.arrival_airport)
|
||||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||||
@@ -1731,28 +1879,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-day span phase
|
// Multi-day span phase (single-leg / non-flight only — a
|
||||||
const spanLabel = getSpanLabel(res, spanPhase)
|
// multi-leg flight is shown as one row per leg, see below).
|
||||||
|
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
|
||||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||||
|
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canEditDays) return
|
if (!canEditDays) return
|
||||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
const target = reservations.find(x => x.id === res.id) ?? res
|
||||||
else onEditReservation?.(res)
|
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||||
|
else onEditReservation?.(target)
|
||||||
}}
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
const inBottom = e.clientY > rect.top + rect.height / 2
|
||||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
|
||||||
|
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||||
}}
|
}}
|
||||||
draggable={canEditDays && spanPhase !== 'middle'}
|
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
||||||
// setData is required for the drag to start reliably (Firefox) and
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
// matches how place/note items initiate their drag.
|
// matches how place/note items initiate their drag.
|
||||||
e.dataTransfer.setData('reservationId', String(res.id))
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
@@ -1773,15 +1925,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
} else if (fromReservationId) {
|
} else if (fromReservationId) {
|
||||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (fromAssignmentId) {
|
} else if (fromAssignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||||
}
|
}
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
@@ -1801,7 +1953,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canEditDays && spanPhase !== 'middle' && (
|
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
||||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1846,7 +1998,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||||
const active = visibleConnectionIds.includes(res.id)
|
const active = visibleConnectionIds.includes(res.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1870,6 +2022,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1977,6 +2130,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{isSelected && hotelLegs.bottom && (
|
||||||
|
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||||
|
)}
|
||||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||||
@@ -2020,8 +2176,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend, #1330) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<button
|
<button
|
||||||
@@ -2037,7 +2193,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
<RouteIcon size={12} strokeWidth={2} />
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
{t('dayplan.route')}
|
{t('dayplan.route')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
|
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
|
||||||
|
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
}}
|
||||||
|
aria-label={t('planner.openGoogleMaps')}
|
||||||
|
title={t('planner.openGoogleMaps')}
|
||||||
|
className="bg-transparent text-content-secondary"
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
|
||||||
|
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
|
||||||
|
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
|
||||||
|
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -2066,7 +2244,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{routeInfo && (
|
{isSelected && routeInfo && (
|
||||||
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
||||||
<span>{routeInfo.distance}</span>
|
<span>{routeInfo.distance}</span>
|
||||||
<span className="text-content-faint">·</span>
|
<span className="text-content-faint">·</span>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={ui.time}
|
value={ui.time}
|
||||||
maxLength={150}
|
maxLength={250}
|
||||||
rows={3}
|
rows={3}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||||
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
|||||||
className="text-content"
|
className="text-content"
|
||||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
||||||
/>
|
/>
|
||||||
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Car, Footprints } from 'lucide-react'
|
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||||
import type { RouteSegment } from '../../types'
|
import type { RouteSegment } from '../../types'
|
||||||
|
|
||||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||||
@@ -19,3 +19,60 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
||||||
|
* accommodation with the drive to/from it. Rendered above the first place (the
|
||||||
|
* morning departure from the hotel) and below the last place (the evening return),
|
||||||
|
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
||||||
|
*/
|
||||||
|
export function HotelRouteConnector({
|
||||||
|
seg,
|
||||||
|
profile,
|
||||||
|
name,
|
||||||
|
placement,
|
||||||
|
}: {
|
||||||
|
seg: RouteSegment
|
||||||
|
profile: 'driving' | 'walking'
|
||||||
|
name: string
|
||||||
|
placement: 'top' | 'bottom'
|
||||||
|
}) {
|
||||||
|
const driving = profile === 'driving'
|
||||||
|
const Icon = driving ? Car : Footprints
|
||||||
|
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||||
|
const hotelRow = (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||||
|
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const travelRow = (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||||
|
<div style={line} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon size={11} strokeWidth={2} />
|
||||||
|
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{seg.distanceText}</span>
|
||||||
|
</div>
|
||||||
|
<div style={line} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||||
|
{placement === 'top' ? (
|
||||||
|
<>
|
||||||
|
{hotelRow}
|
||||||
|
{travelRow}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{travelRow}
|
||||||
|
{hotelRow}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2 } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
|
import { DayReorderPopup } from './DayReorderPopup'
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||||
@@ -27,13 +29,18 @@ interface DayPlanSidebarToolbarProps {
|
|||||||
undoHover: boolean
|
undoHover: boolean
|
||||||
setUndoHover: (v: boolean) => void
|
setUndoHover: (v: boolean) => void
|
||||||
lastActionLabel: string | null
|
lastActionLabel: string | null
|
||||||
|
canEditDays?: boolean
|
||||||
|
onReorderDays?: (orderedIds: number[]) => void
|
||||||
|
onAddDay?: (position?: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DayPlanSidebarToolbar({
|
export function DayPlanSidebarToolbar({
|
||||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||||
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
||||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||||
|
canEditDays, onReorderDays, onAddDay,
|
||||||
}: DayPlanSidebarToolbarProps) {
|
}: DayPlanSidebarToolbarProps) {
|
||||||
|
const [reorderOpen, setReorderOpen] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
@@ -197,6 +204,38 @@ export function DayPlanSidebarToolbar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => setReorderOpen(v => !v)}
|
||||||
|
aria-label={t('dayplan.reorderDays')}
|
||||||
|
aria-pressed={reorderOpen}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 30, height: 30, borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)',
|
||||||
|
background: reorderOpen ? 'var(--bg-hover)' : 'none',
|
||||||
|
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||||
|
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!reorderOpen) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { if (!reorderOpen) e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<DayReorderPopup
|
||||||
|
isOpen={reorderOpen}
|
||||||
|
days={days}
|
||||||
|
t={t}
|
||||||
|
locale={locale}
|
||||||
|
onReorder={onReorderDays}
|
||||||
|
onAddDay={() => onAddDay()}
|
||||||
|
onClose={() => setReorderOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||||
|
import Modal from '../shared/Modal'
|
||||||
|
import type { Day } from '../../types'
|
||||||
|
|
||||||
|
interface DayReorderPopupProps {
|
||||||
|
isOpen: boolean
|
||||||
|
days: Day[]
|
||||||
|
t: (key: string, params?: Record<string, any>) => string
|
||||||
|
locale: string
|
||||||
|
onReorder: (orderedIds: number[]) => void
|
||||||
|
onAddDay: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||||
|
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
||||||
|
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||||
|
* so the list reflects each move immediately.
|
||||||
|
*/
|
||||||
|
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||||
|
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||||
|
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||||
|
|
||||||
|
const label = (day: Day, index: number) => {
|
||||||
|
if (day.title) return day.title
|
||||||
|
if (day.date) {
|
||||||
|
const d = new Date(day.date + 'T00:00:00')
|
||||||
|
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
return t('dayplan.dayN', { n: index + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (from: number, to: number) => {
|
||||||
|
if (to < 0 || to >= ordered.length || from === to) return
|
||||||
|
const ids = ordered.map(d => d.id)
|
||||||
|
const [moved] = ids.splice(from, 1)
|
||||||
|
ids.splice(to, 0, moved)
|
||||||
|
onReorder(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellBtn = {
|
||||||
|
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
||||||
|
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||||
|
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('dayplan.reorderTitle')}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onAddDay}
|
||||||
|
className="bg-accent text-accent-text"
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||||
|
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={15} strokeWidth={2} />
|
||||||
|
{t('dayplan.addDay')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||||
|
{t('dayplan.reorderHint')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{ordered.map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={day.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragIndex(index)}
|
||||||
|
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||||
|
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||||
|
setDragIndex(null); setOverIndex(null)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
||||||
|
borderRadius: 9,
|
||||||
|
border: '1px solid var(--border-faint)',
|
||||||
|
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
||||||
|
opacity: dragIndex === index ? 0.5 : 1,
|
||||||
|
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||||
|
outlineOffset: -2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
||||||
|
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||||
|
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{label(day, index)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => move(index, index - 1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
aria-label={t('dayplan.moveUp')}
|
||||||
|
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => move(index, index + 1)}
|
||||||
|
disabled={index === ordered.length - 1}
|
||||||
|
aria-label={t('dayplan.moveDown')}
|
||||||
|
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface PlaceFormData {
|
|||||||
// Populated from a maps-search pick (not part of the initial blank form).
|
// Populated from a maps-search pick (not part of the initial blank form).
|
||||||
phone?: string
|
phone?: string
|
||||||
google_place_id?: string
|
google_place_id?: string
|
||||||
|
google_ftid?: string
|
||||||
osm_id?: string
|
osm_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
|
|||||||
delete window.__addToast;
|
delete window.__addToast;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
||||||
|
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
||||||
|
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
||||||
|
// it yields no usable place the modal must fall back to the reliable text search
|
||||||
|
// instead of dead-ending on "Place search failed".
|
||||||
|
|
||||||
|
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||||
|
await user.type(searchInput, 'Eiffel');
|
||||||
|
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
||||||
|
return screen.findByText('Paris, France');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
window.__addToast = addToast;
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||||
|
source: 'nominatim',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
||||||
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||||
|
http.post('/api/maps/search', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||||
|
source: 'openstreetmap',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
const suggestion = await openSuggestion(user);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
// Form is populated from the search fallback, and no error toast is shown.
|
||||||
|
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
||||||
|
delete window.__addToast;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||||
|
source: 'nominatim',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||||
|
http.post('/api/maps/search', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||||
|
source: 'openstreetmap',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
const suggestion = await openSuggestion(user);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
window.__addToast = addToast;
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||||
|
source: 'nominatim',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||||
|
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
const suggestion = await openSuggestion(user);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
||||||
|
});
|
||||||
|
delete window.__addToast;
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||||
// hasMapsKey is false by default in beforeEach
|
// hasMapsKey is false by default in beforeEach
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
@@ -304,17 +399,38 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
|
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
|
||||||
|
// Times are per day-assignment; editing a pool place with no day in context
|
||||||
|
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
|
||||||
const place = buildPlace({ name: 'Test' });
|
const place = buildPlace({ name: 'Test' });
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||||
// Time pickers are rendered when editing
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
|
||||||
|
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
|
||||||
|
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
||||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
|
||||||
|
// The pool Place carries no times — they live on the day-assignment. Opening the
|
||||||
|
// editor with an assignmentId must hydrate the fields from assignment.place, not
|
||||||
|
// the (timeless) pool place that the Places panel passes in.
|
||||||
|
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
|
||||||
|
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
|
||||||
|
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
|
||||||
|
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||||
// Build a place with end_time before place_time
|
// Build an assignment whose place has end_time before place_time
|
||||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
||||||
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
||||||
|
|
||||||
// hasTimeError = true → submit button disabled
|
// hasTimeError = true → submit button disabled
|
||||||
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface PlaceFormModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
||||||
place: Place | null
|
place: Place | null
|
||||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
|
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||||
tripId: number
|
tripId: number
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||||
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
|
|||||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||||
* render over the form fields. */
|
* render over the form fields. */
|
||||||
|
|
||||||
|
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||||
|
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
||||||
|
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
||||||
|
const DUP_COORD_TOLERANCE = 0.0001
|
||||||
|
function findDuplicatePlace(
|
||||||
|
form: PlaceFormData,
|
||||||
|
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
||||||
|
): { name?: string | null } | null {
|
||||||
|
const name = (form.name || '').trim().toLowerCase()
|
||||||
|
const gid = (form.google_place_id || '').trim()
|
||||||
|
const lat = form.lat ? parseFloat(form.lat) : null
|
||||||
|
const lng = form.lng ? parseFloat(form.lng) : null
|
||||||
|
for (const p of places || []) {
|
||||||
|
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
||||||
|
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
||||||
|
if (
|
||||||
|
lat != null && lng != null && p.lat != null && p.lng != null &&
|
||||||
|
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
||||||
|
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
||||||
|
) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function usePlaceFormModal(props: PlaceFormModalProps) {
|
function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||||
const {
|
const {
|
||||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||||
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||||
@@ -66,6 +92,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
|
// Times are stored per day-assignment, not on the pool place. When an
|
||||||
|
// assignment is in context (itinerary edit, or a single-assignment pool
|
||||||
|
// edit) read the times off its embedded place; fall back to the place prop.
|
||||||
|
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
||||||
|
const timeSource = assignment?.place ?? place
|
||||||
setForm({
|
setForm({
|
||||||
name: place.name || '',
|
name: place.name || '',
|
||||||
description: place.description || '',
|
description: place.description || '',
|
||||||
@@ -73,8 +104,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lat: place.lat != null ? String(place.lat) : '',
|
lat: place.lat != null ? String(place.lat) : '',
|
||||||
lng: place.lng != null ? String(place.lng) : '',
|
lng: place.lng != null ? String(place.lng) : '',
|
||||||
category_id: place.category_id != null ? String(place.category_id) : '',
|
category_id: place.category_id != null ? String(place.category_id) : '',
|
||||||
place_time: place.place_time || '',
|
place_time: timeSource.place_time || '',
|
||||||
end_time: place.end_time || '',
|
end_time: timeSource.end_time || '',
|
||||||
notes: place.notes || '',
|
notes: place.notes || '',
|
||||||
transport_mode: place.transport_mode || 'walking',
|
transport_mode: place.transport_mode || 'walking',
|
||||||
website: place.website || '',
|
website: place.website || '',
|
||||||
@@ -86,12 +117,19 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lng: String(prefillCoords.lng),
|
lng: String(prefillCoords.lng),
|
||||||
name: prefillCoords.name || '',
|
name: prefillCoords.name || '',
|
||||||
address: prefillCoords.address || '',
|
address: prefillCoords.address || '',
|
||||||
|
website: prefillCoords.website || '',
|
||||||
|
phone: prefillCoords.phone || '',
|
||||||
|
osm_id: prefillCoords.osm_id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
}, [place, prefillCoords, isOpen])
|
setDuplicateWarning(null)
|
||||||
|
// dayAssignments is a fresh array each render; read it at open-time only and
|
||||||
|
// re-run on identity changes (place/assignmentId/open), not on every render.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [place, prefillCoords, isOpen, assignmentId])
|
||||||
|
|
||||||
// Derive location bias bounding box from the trip's existing places
|
// Derive location bias bounding box from the trip's existing places
|
||||||
const places = useTripStore((s) => s.places)
|
const places = useTripStore((s) => s.places)
|
||||||
@@ -179,6 +217,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
address: resolved.address || prev.address,
|
address: resolved.address || prev.address,
|
||||||
lat: String(resolved.lat),
|
lat: String(resolved.lat),
|
||||||
lng: String(resolved.lng),
|
lng: String(resolved.lng),
|
||||||
|
google_ftid: resolved.google_ftid || prev.google_ftid,
|
||||||
}))
|
}))
|
||||||
setMapsResults([])
|
setMapsResults([])
|
||||||
setMapsSearch('')
|
setMapsSearch('')
|
||||||
@@ -203,6 +242,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lat: result.lat || prev.lat,
|
lat: result.lat || prev.lat,
|
||||||
lng: result.lng || prev.lng,
|
lng: result.lng || prev.lng,
|
||||||
google_place_id: result.google_place_id || prev.google_place_id,
|
google_place_id: result.google_place_id || prev.google_place_id,
|
||||||
|
google_ftid: result.google_ftid || prev.google_ftid,
|
||||||
osm_id: result.osm_id || prev.osm_id,
|
osm_id: result.osm_id || prev.osm_id,
|
||||||
website: result.website || prev.website,
|
website: result.website || prev.website,
|
||||||
phone: result.phone || prev.phone,
|
phone: result.phone || prev.phone,
|
||||||
@@ -219,15 +259,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
try {
|
||||||
const result = await mapsApi.details(suggestion.placeId, language)
|
// The details lookup is a fragile second hop — it can fail when the
|
||||||
if (result.place) {
|
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||||
handleSelectMapsResult(result.place)
|
// or on any upstream error. Treat a missing/coordinate-less place as a
|
||||||
|
// miss and fall back to the reliable text-search path the search button
|
||||||
|
// uses (its results already carry coordinates), so dropdown items stay
|
||||||
|
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||||
|
let place: Record<string, unknown> | null = null
|
||||||
|
try {
|
||||||
|
const result = await mapsApi.details(suggestion.placeId, language)
|
||||||
|
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||||
|
place = result.place
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch place details:', err)
|
||||||
|
}
|
||||||
|
if (!place) {
|
||||||
|
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||||
|
const search = await mapsApi.search(query, language)
|
||||||
|
place = search.places?.[0] ?? null
|
||||||
|
}
|
||||||
|
if (place) {
|
||||||
|
handleSelectMapsResult(place)
|
||||||
} else {
|
} else {
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(t('places.mapsSearchError'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch place details:', err)
|
console.error('Place suggestion lookup failed:', err)
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -306,6 +365,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
toast.error(t('places.nameRequired'))
|
toast.error(t('places.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// #1152: only for new places, and only on the first attempt — a second click
|
||||||
|
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
||||||
|
if (!place && !duplicateWarning) {
|
||||||
|
const dup = findDuplicatePlace(form, places)
|
||||||
|
if (dup) {
|
||||||
|
const dupName = dup.name || form.name
|
||||||
|
setDuplicateWarning(dupName)
|
||||||
|
toast.warning(t('places.duplicateExists', { name: dupName }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -378,6 +448,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
duplicateWarning,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +509,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
duplicateWarning,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -460,7 +532,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
disabled={isSaving || hasTimeError}
|
disabled={isSaving || hasTimeError}
|
||||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||||
>
|
>
|
||||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -666,8 +738,11 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time — only shown when editing, not when creating */}
|
{/* Time is per day-assignment: only shown when a single assignment is in
|
||||||
{place && (
|
context (itinerary edit, or a single-assignment pool edit). Hidden when
|
||||||
|
creating, and for unassigned / multi-day pool edits where a single time
|
||||||
|
is ambiguous and wouldn't persist. */}
|
||||||
|
{place && assignmentId && (
|
||||||
<TimeSection
|
<TimeSection
|
||||||
form={form}
|
form={form}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
|||||||
@@ -618,6 +618,22 @@ describe('PlaceInspector', () => {
|
|||||||
expect(mapsBtn).toBeTruthy();
|
expect(mapsBtn).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mapsUrl = "https://www.google.com/maps/place/?q=St.%20Jacobs%20Farmers'%20Market&ftid=0x882bf179e806d471:0x8591dde29c821a93";
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
render(<PlaceInspector {...defaultProps} place={buildPlace({
|
||||||
|
name: "St. Jacobs Farmers' Market",
|
||||||
|
lat: 43.5118527,
|
||||||
|
lng: -80.5542617,
|
||||||
|
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
|
||||||
|
})} />);
|
||||||
|
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
|
||||||
|
await user.click(mapsBtn);
|
||||||
|
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
|
||||||
|
openSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
// ── No files section when no upload handler and no files ──────────────────
|
// ── No files section when no upload handler and no files ──────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
|
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
|
||||||
@@ -647,5 +663,42 @@ describe('PlaceInspector', () => {
|
|||||||
expect(screen.queryByText('Participants')).toBeNull();
|
expect(screen.queryByText('Participants')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||||||
|
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||||
|
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||||
|
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||||
|
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||||
|
expect(scroll.style.overflowY).toBe('auto');
|
||||||
|
expect(scroll.style.minHeight).toBe('0px');
|
||||||
|
// flex must allow the region to shrink/grow within the capped card
|
||||||
|
expect(scroll.style.flex).not.toBe('');
|
||||||
|
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||||
|
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||||||
|
const p = buildPlace({ id: 201, description: longWord } as any);
|
||||||
|
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||||
|
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||||||
|
expect(descDiv).toBeTruthy();
|
||||||
|
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||||||
|
expect(descDiv.style.wordBreak).toBe('break-word');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||||||
|
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||||
|
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||||
|
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||||
|
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||||
|
// Both description and notes containers must keep their natural height
|
||||||
|
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||||
|
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||||
|
expect(notes.length).toBe(2);
|
||||||
|
for (const el of notes) {
|
||||||
|
expect(el.style.flexShrink).toBe('0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||||
|
import { formatDistance, formatElevation } from '../../utils/units'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
const detailsCache = new Map()
|
const detailsCache = new Map()
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ export default function PlaceInspector({
|
|||||||
const { t, locale, language } = useTranslation()
|
const { t, locale, language } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
|
||||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
const [filesExpanded, setFilesExpanded] = useState(false)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
@@ -162,6 +165,11 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
const openingHours = googleDetails?.opening_hours || null
|
const openingHours = googleDetails?.opening_hours || null
|
||||||
const openNow = googleDetails?.open_now ?? null
|
const openNow = googleDetails?.open_now ?? null
|
||||||
|
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
|
||||||
|
const googleMapsUrl = getGoogleMapsUrlForPlace(
|
||||||
|
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
|
||||||
|
googleDetails?.google_maps_url,
|
||||||
|
)
|
||||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||||
|
|
||||||
@@ -217,7 +225,7 @@ export default function PlaceInspector({
|
|||||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||||
|
|
||||||
{/* Content — scrollable */}
|
{/* Content — scrollable */}
|
||||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
|
||||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||||
@@ -253,14 +261,14 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || googleDetails?.summary) && (
|
{(place.description || googleDetails?.summary) && (
|
||||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{place.notes && (
|
{place.notes && (
|
||||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -274,12 +282,13 @@ export default function PlaceInspector({
|
|||||||
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
|
<PlaceExtras openingHours={openingHours} weekdayIndex={weekdayIndex} hoursExpanded={hoursExpanded}
|
||||||
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
|
setHoursExpanded={setHoursExpanded} timeFormat={timeFormat} t={t} place={place} placeFiles={placeFiles}
|
||||||
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
|
onFileUpload={onFileUpload} filesExpanded={filesExpanded} setFilesExpanded={setFilesExpanded}
|
||||||
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading} />
|
fileInputRef={fileInputRef} handleFileUpload={handleFileUpload} isUploading={isUploading}
|
||||||
|
distanceUnit={distanceUnit} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
{selectedDayId && (
|
{selectedDayId && (
|
||||||
assignmentInDay ? (
|
assignmentInDay ? (
|
||||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||||
@@ -288,14 +297,10 @@ export default function PlaceInspector({
|
|||||||
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{googleDetails?.google_maps_url && (
|
{googleMapsUrl && (
|
||||||
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
<ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||||
)}
|
)}
|
||||||
{!googleDetails?.google_maps_url && place.lat && place.lng && (
|
|
||||||
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
|
||||||
label={<span className="hidden sm:inline">Google Maps</span>} />
|
|
||||||
)}
|
|
||||||
{(place.website || googleDetails?.website) && (
|
{(place.website || googleDetails?.website) && (
|
||||||
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||||
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
||||||
@@ -497,7 +502,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
|||||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
{/* Avatar with open/closed ring + tag */}
|
{/* Avatar with open/closed ring + tag */}
|
||||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -682,7 +687,7 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
|
function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpanded, timeFormat, t, place,
|
||||||
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading }: any) {
|
placeFiles, onFileUpload, filesExpanded, setFilesExpanded, fileInputRef, handleFileUpload, isUploading, distanceUnit }: any) {
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
||||||
{openingHours && openingHours.length > 0 && (
|
{openingHours && openingHours.length > 0 && (
|
||||||
@@ -775,20 +780,20 @@ function PlaceExtras({ openingHours, weekdayIndex, hoursExpanded, setHoursExpand
|
|||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
||||||
<MapPin size={12} color="#3b82f6" />
|
<MapPin size={12} color="#3b82f6" />
|
||||||
{distKm < 1 ? `${Math.round(totalDist)} m` : `${distKm.toFixed(1)} km`}
|
{formatDistance(distKm, distanceUnit)}
|
||||||
</div>
|
</div>
|
||||||
{hasEle && (
|
{hasEle && (
|
||||||
<>
|
<>
|
||||||
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
||||||
<Mountain size={12} color="#22c55e" />
|
<Mountain size={12} color="#22c55e" />
|
||||||
{Math.round(maxEle)} m
|
{formatElevation(maxEle, distanceUnit)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
<div className="text-content" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, fontWeight: 600 }}>
|
||||||
<Mountain size={12} color="#ef4444" />
|
<Mountain size={12} color="#ef4444" />
|
||||||
{Math.round(minEle)} m
|
{formatElevation(minEle, distanceUnit)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-content-muted" style={{ fontSize: 12 }}>
|
<div className="text-content-muted" style={{ fontSize: 12 }}>
|
||||||
↑{Math.round(totalUp)} m ↓{Math.round(totalDown)} m
|
↑{formatElevation(totalUp, distanceUnit)} ↓{formatElevation(totalDown, distanceUnit)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -124,6 +124,40 @@ describe('PlacesSidebar', () => {
|
|||||||
expect(screen.getByText('Central Park')).toBeInTheDocument();
|
expect(screen.getByText('Central Park')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PLACES-009a: selected visible place is scrolled into view', async () => {
|
||||||
|
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
scrollIntoView.mockClear();
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 10, name: 'First Place' }),
|
||||||
|
buildPlace({ id: 42, name: 'Map Click Target' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
|
||||||
|
|
||||||
|
const selectedRow = screen.getByText('Map Click Target').closest('[data-place-id="42"]');
|
||||||
|
expect(selectedRow).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-PLACES-009b: selected place hidden by search is not scrolled', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const scrollIntoView = Element.prototype.scrollIntoView as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 10, name: 'Visible Cafe' }),
|
||||||
|
buildPlace({ id: 42, name: 'Hidden Museum' }),
|
||||||
|
];
|
||||||
|
const { rerender } = render(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={null} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText(/Search places/i), 'Visible');
|
||||||
|
scrollIntoView.mockClear();
|
||||||
|
rerender(<PlacesSidebar {...defaultProps} places={places} selectedPlaceId={42} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Hidden Museum')).not.toBeInTheDocument();
|
||||||
|
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-PLACES-010: shows place count', () => {
|
it('FE-COMP-PLACES-010: shows place count', () => {
|
||||||
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
|
const places = [buildPlace({ name: 'P1' }), buildPlace({ name: 'P2' }), buildPlace({ name: 'P3' })];
|
||||||
render(<PlacesSidebar {...defaultProps} places={places} />);
|
render(<PlacesSidebar {...defaultProps} places={places} />);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function PlacesList(S: SidebarState) {
|
|||||||
const {
|
const {
|
||||||
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
|
filtered, scrollContainerRef, onScrollTopChange, filter, t, canEditPlaces, onAddPlace,
|
||||||
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
|
categories, selectedPlaceId, plannedIds, inDaySet, selectedIds, selectMode, selectedDayId,
|
||||||
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
isMobile, onPlaceClick, openContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||||
@@ -44,6 +44,7 @@ export function PlacesList(S: SidebarState) {
|
|||||||
onAssignToDay={onAssignToDay}
|
onAssignToDay={onAssignToDay}
|
||||||
toggleSelected={toggleSelected}
|
toggleSelected={toggleSelected}
|
||||||
setDayPickerPlace={setDayPickerPlace}
|
setDayPickerPlace={setDayPickerPlace}
|
||||||
|
registerPlaceRow={registerPlaceRow}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
|
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||||
import type { SidebarState } from './usePlacesSidebar'
|
import type { SidebarState } from './usePlacesSidebar'
|
||||||
|
|
||||||
export function ListImportModal(S: SidebarState) {
|
export function ListImportModal(S: SidebarState) {
|
||||||
const {
|
const {
|
||||||
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
||||||
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
||||||
|
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||||
} = S
|
} = S
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
|
|||||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{canEnrichImport && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||||
|
|||||||
@@ -21,17 +21,21 @@ interface MemoPlaceRowProps {
|
|||||||
onAssignToDay: (placeId: number, dayId?: number) => void
|
onAssignToDay: (placeId: number, dayId?: number) => void
|
||||||
toggleSelected: (id: number) => void
|
toggleSelected: (id: number) => void
|
||||||
setDayPickerPlace: (place: any) => void
|
setDayPickerPlace: (place: any) => void
|
||||||
|
registerPlaceRow: (placeId: number, element: HTMLDivElement | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||||
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
||||||
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
||||||
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, registerPlaceRow,
|
||||||
}: MemoPlaceRowProps) {
|
}: MemoPlaceRowProps) {
|
||||||
const hasGeometry = Boolean(place.route_geometry)
|
const hasGeometry = Boolean(place.route_geometry)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={place.id}
|
key={place.id}
|
||||||
|
ref={element => registerPlaceRow(place.id, element)}
|
||||||
|
aria-selected={isSelected}
|
||||||
|
data-place-id={place.id}
|
||||||
draggable={!selectMode}
|
draggable={!selectMode}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
e.dataTransfer.setData('placeId', String(place.id))
|
e.dataTransfer.setData('placeId', String(place.id))
|
||||||
|
|||||||
@@ -343,56 +343,51 @@ describe('ReservationModal', () => {
|
|||||||
|
|
||||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
|
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
|
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<ReservationModal {...defaultProps} />);
|
const onSave = vi.fn().mockResolvedValue({ id: 55 });
|
||||||
const priceInput = screen.getByPlaceholderText('0.00');
|
const onOpenExpense = vi.fn();
|
||||||
await userEvent.type(priceInput, '99.99');
|
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||||
expect((priceInput as HTMLInputElement).value).toBe('99.99');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} />);
|
|
||||||
const priceInput = screen.getByPlaceholderText('0.00');
|
|
||||||
await userEvent.type(priceInput, '50');
|
|
||||||
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
|
||||||
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
||||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
|
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
expect(onSave).toHaveBeenCalledWith(
|
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
|
await waitFor(() =>
|
||||||
|
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
trip: buildTrip({ id: 1 }),
|
||||||
|
budgetItems: [
|
||||||
|
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
|
||||||
|
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
// ── File upload ───────────────────────────────────────────────────────────────
|
// ── File upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
||||||
@@ -599,22 +594,6 @@ describe('ReservationModal', () => {
|
|||||||
expect(filePickerItem).toBeInTheDocument();
|
expect(filePickerItem).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
seedStore(useTripStore, {
|
|
||||||
trip: buildTrip({ id: 1 }),
|
|
||||||
budgetItems: [
|
|
||||||
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} />);
|
|
||||||
// Budget section is visible
|
|
||||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||||
@@ -632,31 +611,6 @@ describe('ReservationModal', () => {
|
|||||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
seedStore(useTripStore, {
|
|
||||||
trip: buildTrip({ id: 1 }),
|
|
||||||
budgetItems: [
|
|
||||||
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} />);
|
|
||||||
|
|
||||||
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
|
|
||||||
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
|
|
||||||
await userEvent.click(budgetCategoryBtn);
|
|
||||||
|
|
||||||
// Click the "Transport" category option
|
|
||||||
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
|
|
||||||
await userEvent.click(screen.getByText('Transport'));
|
|
||||||
|
|
||||||
// The select should now show "Transport"
|
|
||||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||||
|
import { BookingCostsSection } from './BookingCostsSection'
|
||||||
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||||
|
import { typeToCostCategory } from '@trek/shared'
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||||
@@ -60,9 +63,10 @@ interface ReservationModalProps {
|
|||||||
onFileDelete: (fileId: number) => Promise<void>
|
onFileDelete: (fileId: number) => Promise<void>
|
||||||
accommodations?: Accommodation[]
|
accommodations?: Accommodation[]
|
||||||
defaultAssignmentId?: number | null
|
defaultAssignmentId?: number | null
|
||||||
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
|
||||||
const { id: tripId } = useParams<{ id: string }>()
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
const loadFiles = useTripStore(s => s.loadFiles)
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -70,18 +74,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||||
const budgetCategories = useMemo(() => {
|
// Set right before submit when the user clicked create/edit expense (see TransportModal).
|
||||||
const cats = new Set<string>()
|
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
|
||||||
return Array.from(cats).sort()
|
|
||||||
}, [budgetItems])
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||||
price: '', budget_category: '',
|
|
||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||||
})
|
})
|
||||||
@@ -127,15 +127,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||||
price: meta.price || '',
|
|
||||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||||
price: '', budget_category: '',
|
|
||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||||
})
|
})
|
||||||
@@ -167,8 +164,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
return endFull <= startFull
|
return endFull <= startFull
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||||
e.preventDefault()
|
e?.preventDefault?.()
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
@@ -185,11 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
} else if (form.reservation_end_time && form.reservation_time) {
|
} else if (form.reservation_end_time && form.reservation_time) {
|
||||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
if (form.price) metadata.price = form.price
|
|
||||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveData: Record<string, any> & { title: string } = {
|
const saveData: Record<string, any> & { title: string } = {
|
||||||
title: form.title, type: form.type, status: form.status,
|
title: form.title, type: form.type, status: form.status,
|
||||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||||
@@ -202,11 +194,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
endpoints: [],
|
endpoints: [],
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
|
||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
|
||||||
: { total_price: 0 }
|
|
||||||
}
|
|
||||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||||
saveData.create_accommodation = {
|
saveData.create_accommodation = {
|
||||||
place_id: form.hotel_place_id || null,
|
place_id: form.hotel_place_id || null,
|
||||||
@@ -228,11 +215,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
await onFileUpload(fd)
|
await onFileUpload(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Open the Costs editor for the saved booking when the user asked to
|
||||||
|
// create/edit its linked expense (gated on saved?.id).
|
||||||
|
const intent = expenseIntentRef.current
|
||||||
|
expenseIntentRef.current = null
|
||||||
|
if (intent && onOpenExpense && saved?.id) {
|
||||||
|
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||||
|
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||||
|
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||||
|
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||||
|
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileChange = async (e) => {
|
const handleFileChange = async (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -610,38 +611,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Costs — create / view the expense linked to this booking */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<>
|
<BookingCostsSection
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
reservationId={reservation?.id ?? null}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
onCreate={handleCreateExpense}
|
||||||
<label className={labelClass}>{t('reservations.price')}</label>
|
onEdit={handleEditExpense}
|
||||||
<input type="text" inputMode="decimal" value={form.price}
|
onRemove={handleRemoveExpense}
|
||||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
/>
|
||||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
|
||||||
placeholder="0.00"
|
|
||||||
className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.budget_category}
|
|
||||||
onChange={v => set('budget_category', v)}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
|
||||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
|
||||||
]}
|
|
||||||
placeholder={t('reservations.budgetCategoryAuto')}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.price && parseFloat(form.price) > 0 && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
|
||||||
{t('reservations.budgetHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{t('reservations.needsReview')}
|
{t('reservations.needsReview')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{r.external_source === 'airtrail' ? (
|
||||||
|
<span
|
||||||
|
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
|
||||||
|
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
|
||||||
|
>
|
||||||
|
<Plane size={11} />
|
||||||
|
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<span className="text-content" style={{
|
<span className="text-content" style={{
|
||||||
@@ -271,19 +281,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const eps = r.endpoints || []
|
// Full route over all waypoints (from · stops · to), ordered by sequence.
|
||||||
const from = eps.find(e => e.role === 'from')
|
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||||
const to = eps.find(e => e.role === 'to')
|
if (eps.length < 2) return null
|
||||||
if (!from || !to) return null
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-tertiary text-content" style={{
|
<div className="bg-surface-tertiary text-content" style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
padding: '8px 12px', borderRadius: 10,
|
padding: '8px 12px', borderRadius: 10,
|
||||||
fontSize: 12.5,
|
fontSize: 12.5, flexWrap: 'wrap',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
|
{eps.map((ep, i) => (
|
||||||
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
{i > 0 && <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />}
|
||||||
|
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ep.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -470,6 +482,8 @@ interface ReservationsPanelProps {
|
|||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onImport?: () => void
|
onImport?: () => void
|
||||||
bookingImportAvailable?: boolean
|
bookingImportAvailable?: boolean
|
||||||
|
onAirTrailImport?: () => void
|
||||||
|
airTrailAvailable?: boolean
|
||||||
onEdit: (reservation: Reservation) => void
|
onEdit: (reservation: Reservation) => void
|
||||||
onDelete: (id: number) => void
|
onDelete: (id: number) => void
|
||||||
onNavigateToFiles: () => void
|
onNavigateToFiles: () => void
|
||||||
@@ -477,7 +491,7 @@ interface ReservationsPanelProps {
|
|||||||
addManualKey?: string
|
addManualKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
@@ -600,6 +614,21 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onAirTrailImport && airTrailAvailable && (
|
||||||
|
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
|
||||||
|
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
title={t('reservations.airtrail.title')}
|
||||||
|
>
|
||||||
|
<Plane size={14} strokeWidth={2} />
|
||||||
|
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
|||||||
@@ -132,34 +132,37 @@ describe('TransportModal', () => {
|
|||||||
|
|
||||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<TransportModal {...defaultProps} />);
|
render(<TransportModal {...defaultProps} />);
|
||||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
||||||
render(<TransportModal {...defaultProps} />);
|
render(<TransportModal {...defaultProps} />);
|
||||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
||||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
const onOpenExpense = vi.fn();
|
||||||
|
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
expect(onSave).toHaveBeenCalledWith(
|
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
||||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
@@ -13,7 +13,11 @@ import { useAddonStore } from '../../store/addonStore'
|
|||||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||||
|
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||||
|
import { BookingCostsSection } from './BookingCostsSection'
|
||||||
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||||
|
import { typeToCostCategory } from '@trek/shared'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
@@ -23,7 +27,7 @@ interface EndpointPick {
|
|||||||
location?: LocationPoint
|
location?: LocationPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||||
return {
|
return {
|
||||||
role, sequence,
|
role, sequence,
|
||||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||||
@@ -63,6 +67,25 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
|
|||||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
|
||||||
|
// A flight is an ordered list of airports. The origin has only a departure, the
|
||||||
|
// destination only an arrival, and each intermediate stop has both — plus the
|
||||||
|
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
|
||||||
|
// single-leg flight is just two waypoints, so it persists exactly as before.
|
||||||
|
interface WaypointForm {
|
||||||
|
airport: Airport | null
|
||||||
|
arrDayId: string | number
|
||||||
|
arrTime: string
|
||||||
|
depDayId: string | number
|
||||||
|
depTime: string
|
||||||
|
airline: string
|
||||||
|
flight_number: string
|
||||||
|
seat: string
|
||||||
|
}
|
||||||
|
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||||
|
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||||
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||||
@@ -85,8 +108,6 @@ const defaultForm = {
|
|||||||
arrival_time: '',
|
arrival_time: '',
|
||||||
confirmation_number: '',
|
confirmation_number: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
price: '',
|
|
||||||
budget_category: '',
|
|
||||||
meta_airline: '',
|
meta_airline: '',
|
||||||
meta_flight_number: '',
|
meta_flight_number: '',
|
||||||
meta_train_number: '',
|
meta_train_number: '',
|
||||||
@@ -104,24 +125,26 @@ interface TransportModalProps {
|
|||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||||
onFileDelete?: (fileId: number) => Promise<void>
|
onFileDelete?: (fileId: number) => Promise<void>
|
||||||
|
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
|
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||||
const loadFiles = useTripStore(s => s.loadFiles)
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const budgetCategories = useMemo(() => {
|
|
||||||
const cats = new Set<string>()
|
|
||||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
|
||||||
return Array.from(cats).sort()
|
|
||||||
}, [budgetItems])
|
|
||||||
const { id: tripId } = useParams<{ id: string }>()
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
|
// Set right before submitting when the user clicked "create/edit expense", so
|
||||||
|
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||||
|
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||||
|
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||||
|
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
@@ -155,12 +178,42 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
meta_train_number: meta.train_number || '',
|
meta_train_number: meta.train_number || '',
|
||||||
meta_platform: meta.platform || '',
|
meta_platform: meta.platform || '',
|
||||||
meta_seat: meta.seat || '',
|
meta_seat: meta.seat || '',
|
||||||
price: meta.price || '',
|
|
||||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
|
||||||
})
|
})
|
||||||
if (type === 'flight') {
|
if (type === 'flight') {
|
||||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
const orderedEps = orderedEndpoints(reservation)
|
||||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||||
|
let wps: WaypointForm[]
|
||||||
|
if (orderedEps.length >= 2) {
|
||||||
|
wps = orderedEps.map((ep, i) => {
|
||||||
|
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
|
||||||
|
const legOut = metaLegs[i] // leg departing FROM waypoint i
|
||||||
|
const isFirst = i === 0
|
||||||
|
const isLast = i === orderedEps.length - 1
|
||||||
|
return {
|
||||||
|
airport: airportFromEndpoint(ep),
|
||||||
|
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
|
||||||
|
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||||
|
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
|
||||||
|
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||||
|
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||||
|
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||||
|
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
||||||
|
const dep = emptyWaypoint(reservation.day_id ?? '')
|
||||||
|
dep.airport = airportFromEndpoint(from)
|
||||||
|
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||||
|
dep.airline = meta.airline ?? ''
|
||||||
|
dep.flight_number = meta.flight_number ?? ''
|
||||||
|
dep.seat = meta.seat ?? ''
|
||||||
|
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||||
|
arr.airport = airportFromEndpoint(to)
|
||||||
|
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||||
|
wps = [dep, arr]
|
||||||
|
}
|
||||||
|
setWaypoints(wps)
|
||||||
} else {
|
} else {
|
||||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||||
@@ -169,13 +222,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||||
setFromPick({})
|
setFromPick({})
|
||||||
setToPick({})
|
setToPick({})
|
||||||
|
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||||
}
|
}
|
||||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||||
|
|
||||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e?.preventDefault()
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -187,47 +241,86 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
return day?.date ? `${day.date}T${time}` : time
|
return day?.date ? `${day.date}T${time}` : time
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: Record<string, string> = {}
|
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
|
||||||
|
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||||
|
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
||||||
|
const firstWp = flightWps[0]
|
||||||
|
const lastWp = flightWps[flightWps.length - 1]
|
||||||
|
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
||||||
|
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
||||||
|
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
||||||
|
|
||||||
|
const metadata: Record<string, any> = {}
|
||||||
if (form.type === 'flight') {
|
if (form.type === 'flight') {
|
||||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
// Top-level keys mirror the first/last leg so legacy readers keep working.
|
||||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
if (firstWp?.airline) metadata.airline = firstWp.airline
|
||||||
if (fromPick.airport) {
|
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
|
||||||
metadata.departure_airport = fromPick.airport.iata
|
if (firstWp?.airport) {
|
||||||
metadata.departure_timezone = fromPick.airport.tz
|
metadata.departure_airport = firstWp.airport.iata
|
||||||
|
metadata.departure_timezone = firstWp.airport.tz
|
||||||
}
|
}
|
||||||
if (toPick.airport) {
|
if (lastWp?.airport) {
|
||||||
metadata.arrival_airport = toPick.airport.iata
|
metadata.arrival_airport = lastWp.airport.iata
|
||||||
metadata.arrival_timezone = toPick.airport.tz
|
metadata.arrival_timezone = lastWp.airport.tz
|
||||||
}
|
}
|
||||||
|
// Per-leg detail only for true multi-leg flights — a single-leg flight
|
||||||
|
// keeps the exact same (flat) metadata it had before this feature.
|
||||||
|
if (flightWps.length > 2) {
|
||||||
|
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
|
||||||
|
const next = flightWps[i + 1]
|
||||||
|
return {
|
||||||
|
from: w.airport!.iata,
|
||||||
|
to: next.airport!.iata,
|
||||||
|
...(w.airline ? { airline: w.airline } : {}),
|
||||||
|
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
||||||
|
...(w.seat ? { seat: w.seat } : {}),
|
||||||
|
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||||
|
dep_time: w.depTime || null,
|
||||||
|
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||||
|
arr_time: next.arrTime || null,
|
||||||
|
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||||
} else if (form.type === 'train') {
|
} else if (form.type === 'train') {
|
||||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
if (form.price) metadata.price = form.price
|
|
||||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = startDay?.date ?? null
|
const startDate = startDay?.date ?? null
|
||||||
const endDate = (endDay ?? startDay)?.date ?? null
|
const endDate = (endDay ?? startDay)?.date ?? null
|
||||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||||
if (form.type === 'flight') {
|
if (form.type === 'flight') {
|
||||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
flightWps.forEach((w, i) => {
|
||||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
const isFirst = i === 0
|
||||||
|
const isLast = i === flightWps.length - 1
|
||||||
|
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
||||||
|
const dId = isLast ? w.arrDayId : w.depDayId
|
||||||
|
const time = isLast ? w.arrTime : w.depTime
|
||||||
|
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flights derive their span from the first/last waypoint; other transports
|
||||||
|
// keep using the single departure/arrival form fields unchanged.
|
||||||
|
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||||
|
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
||||||
const payload = {
|
const payload = {
|
||||||
title: form.title,
|
title: form.title,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||||
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||||
reservation_time: buildTime(startDay, form.departure_time),
|
reservation_time: form.type === 'flight'
|
||||||
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
||||||
|
: buildTime(startDay, form.departure_time),
|
||||||
|
reservation_end_time: form.type === 'flight'
|
||||||
|
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
||||||
|
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||||
location: null,
|
location: null,
|
||||||
confirmation_number: form.confirmation_number || null,
|
confirmation_number: form.confirmation_number || null,
|
||||||
notes: form.notes || null,
|
notes: form.notes || null,
|
||||||
@@ -235,11 +328,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
endpoints,
|
endpoints,
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
if (isBudgetEnabled) {
|
|
||||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
|
||||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
|
||||||
: { total_price: 0 }
|
|
||||||
}
|
|
||||||
const saved = await onSave(payload)
|
const saved = await onSave(payload)
|
||||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
@@ -250,6 +338,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
await onFileUpload(fd)
|
await onFileUpload(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The user asked to create/edit the linked expense — open the Costs editor
|
||||||
|
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
||||||
|
const intent = expenseIntentRef.current
|
||||||
|
expenseIntentRef.current = null
|
||||||
|
if (intent && onOpenExpense && saved?.id) {
|
||||||
|
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||||
|
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -257,6 +353,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||||
|
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||||
|
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||||
|
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -348,100 +450,130 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* From / To endpoints */}
|
{form.type === 'flight' ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
/* ── Flight route: ordered airports (origin · stops · destination) ── */
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||||
{form.type === 'flight' ? (
|
{waypoints.map((wp, i) => {
|
||||||
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
const isFirst = i === 0
|
||||||
) : (
|
const isLast = i === waypoints.length - 1
|
||||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||||||
)}
|
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
|
||||||
|
</div>
|
||||||
|
{!isFirst && !isLast && (
|
||||||
|
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isFirst && (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
||||||
|
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
||||||
|
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
||||||
|
</div>
|
||||||
|
{wp.airport && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||||
|
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLast && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
||||||
|
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
||||||
|
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||||
|
</div>
|
||||||
|
{wp.airport && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
||||||
|
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||||
|
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||||
|
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||||
|
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<button type="button" onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
||||||
|
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
<>
|
||||||
{form.type === 'flight' ? (
|
{/* From / To endpoints (non-flight) */}
|
||||||
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
) : (
|
<div>
|
||||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
||||||
)}
|
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||||
{/* Departure row */}
|
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>
|
|
||||||
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
|
|
||||||
</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.start_day_id}
|
|
||||||
onChange={value => set('start_day_id', value)}
|
|
||||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
|
||||||
options={dayOptions}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>
|
|
||||||
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
|
|
||||||
</label>
|
|
||||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
|
||||||
</div>
|
|
||||||
{form.type === 'flight' && fromPick.airport && (
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
|
||||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
|
||||||
{fromPick.airport.tz}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrival row */}
|
{/* Departure row */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label className={labelClass}>
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||||
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||||
</label>
|
</div>
|
||||||
<CustomSelect
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
value={form.end_day_id}
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||||
onChange={value => set('end_day_id', value)}
|
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
|
||||||
options={dayOptions}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>
|
|
||||||
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
|
|
||||||
</label>
|
|
||||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
|
||||||
</div>
|
|
||||||
{form.type === 'flight' && toPick.airport && (
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
|
||||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
|
||||||
{toPick.airport.tz}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flight-specific fields */}
|
{/* Arrival row */}
|
||||||
{form.type === 'flight' && (
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div>
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
</div>
|
||||||
placeholder="Lufthansa" className={inputClass} />
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||||
|
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</>
|
||||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
|
||||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
|
||||||
placeholder="LH 123" className={inputClass} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Train-specific fields */}
|
{/* Train-specific fields */}
|
||||||
@@ -583,38 +715,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price + Budget Category */}
|
{/* Costs — create / view the expense linked to this booking */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<>
|
<BookingCostsSection
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
reservationId={reservation?.id ?? null}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
onCreate={handleCreateExpense}
|
||||||
<label className={labelClass}>{t('reservations.price')}</label>
|
onEdit={handleEditExpense}
|
||||||
<input type="text" inputMode="decimal" value={form.price}
|
onRemove={handleRemoveExpense}
|
||||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
/>
|
||||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
|
||||||
placeholder="0.00"
|
|
||||||
className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.budget_category}
|
|
||||||
onChange={v => set('budget_category', v)}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
|
||||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
|
||||||
]}
|
|
||||||
placeholder={t('reservations.budgetCategoryAuto')}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{form.price && parseFloat(form.price) > 0 && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
|
||||||
{t('reservations.budgetHint')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
|
const base = { name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, google_place_id: null, google_ftid: null } as any
|
||||||
|
|
||||||
|
describe('getGoogleMapsUrlForPlace', () => {
|
||||||
|
it('FE-PLACE-GMAPS-001: uses a valid ftid for a precise /place link', () => {
|
||||||
|
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0x47e66e2964e34e2d:0x8ddca9ee380ef7e0' })
|
||||||
|
expect(url).toBe('https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x47e66e2964e34e2d:0x8ddca9ee380ef7e0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLACE-GMAPS-002: falls back to query_place_id when there is no ftid', () => {
|
||||||
|
const url = getGoogleMapsUrlForPlace({ ...base, google_place_id: 'ChIJ123' })
|
||||||
|
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLACE-GMAPS-003: ignores a malformed/hostile ftid and falls through to the place id', () => {
|
||||||
|
const url = getGoogleMapsUrlForPlace({ ...base, google_ftid: '0xAB&q=evil', google_place_id: 'ChIJ123' })
|
||||||
|
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=Eiffel%20Tower&query_place_id=ChIJ123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLACE-GMAPS-004: uses the details URL when there is no ftid or place id', () => {
|
||||||
|
const url = getGoogleMapsUrlForPlace(base, 'https://maps.google.com/?cid=123')
|
||||||
|
expect(url).toBe('https://maps.google.com/?cid=123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLACE-GMAPS-005: falls back to coordinates as a last resort', () => {
|
||||||
|
const url = getGoogleMapsUrlForPlace(base)
|
||||||
|
expect(url).toBe('https://www.google.com/maps/search/?api=1&query=48.8584,2.2945')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLACE-GMAPS-006: returns null for no place or no location', () => {
|
||||||
|
expect(getGoogleMapsUrlForPlace(null)).toBeNull()
|
||||||
|
expect(getGoogleMapsUrlForPlace({ ...base, lat: null, lng: null })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AssignmentPlace, Place } from '../../types'
|
||||||
|
|
||||||
|
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
|
||||||
|
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
|
||||||
|
|
||||||
|
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
|
||||||
|
if (!place) return null
|
||||||
|
const ftid = place.google_ftid?.trim()
|
||||||
|
if (ftid && GOOGLE_FTID_RE.test(ftid)) {
|
||||||
|
return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}`
|
||||||
|
}
|
||||||
|
const placeId = place.google_place_id?.trim()
|
||||||
|
if (placeId) {
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
|
||||||
|
}
|
||||||
|
if (detailsUrl) return detailsUrl
|
||||||
|
if (place.lat == null || place.lng == null) return null
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import { useContextMenu } from '../shared/ContextMenu'
|
|||||||
import { placesApi } from '../../api/client'
|
import { placesApi } from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||||
|
|
||||||
export interface PlacesSidebarProps {
|
export interface PlacesSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -49,6 +51,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditPlaces = can('place_edit', trip)
|
const canEditPlaces = can('place_edit', trip)
|
||||||
|
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
||||||
|
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
||||||
const isNaverListImportEnabled = true
|
const isNaverListImportEnabled = true
|
||||||
|
|
||||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||||
@@ -56,6 +60,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
||||||
const sidebarDragCounter = useRef(0)
|
const sidebarDragCounter = useRef(0)
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const placeRowRefs = useRef(new Map<number, HTMLDivElement>())
|
||||||
|
const lastAutoScrolledPlaceIdRef = useRef<number | null>(null)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (scrollContainerRef.current && initialScrollTop) {
|
if (scrollContainerRef.current && initialScrollTop) {
|
||||||
scrollContainerRef.current.scrollTop = initialScrollTop
|
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||||
@@ -94,6 +100,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const [listImportUrl, setListImportUrl] = useState('')
|
const [listImportUrl, setListImportUrl] = useState('')
|
||||||
const [listImportLoading, setListImportLoading] = useState(false)
|
const [listImportLoading, setListImportLoading] = useState(false)
|
||||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||||
|
const [listImportEnrich, setListImportEnrich] = useState(false)
|
||||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||||
|
|
||||||
@@ -108,9 +115,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
setListImportLoading(true)
|
setListImportLoading(true)
|
||||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||||
try {
|
try {
|
||||||
|
const enrich = listImportEnrich && canEnrichImport
|
||||||
const result = provider === 'google'
|
const result = provider === 'google'
|
||||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
|
||||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
|
||||||
await loadTrip(tripId)
|
await loadTrip(tripId)
|
||||||
if (result.count === 0 && result.skipped > 0) {
|
if (result.count === 0 && result.skipped > 0) {
|
||||||
toast.warning(t('places.importAllSkipped'))
|
toast.warning(t('places.importAllSkipped'))
|
||||||
@@ -192,6 +200,28 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
return true
|
return true
|
||||||
}), [places, filter, categoryFilters, search, plannedIds])
|
}), [places, filter, categoryFilters, search, plannedIds])
|
||||||
|
|
||||||
|
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
||||||
|
if (element) {
|
||||||
|
placeRowRefs.current.set(placeId, element)
|
||||||
|
} else {
|
||||||
|
placeRowRefs.current.delete(placeId)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!props.selectedPlaceId) {
|
||||||
|
lastAutoScrolledPlaceIdRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lastAutoScrolledPlaceIdRef.current === props.selectedPlaceId) return
|
||||||
|
if (!filtered.some(place => place.id === props.selectedPlaceId)) return
|
||||||
|
|
||||||
|
const selectedRow = placeRowRefs.current.get(props.selectedPlaceId)
|
||||||
|
if (!selectedRow) return
|
||||||
|
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId
|
||||||
|
}, [filtered, props.selectedPlaceId])
|
||||||
|
|
||||||
const isAssignedToSelectedDay = (placeId) =>
|
const isAssignedToSelectedDay = (placeId) =>
|
||||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||||
|
|
||||||
@@ -205,11 +235,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
|
|
||||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||||
const selDayId = selectedDayIdRef.current
|
const selDayId = selectedDayIdRef.current
|
||||||
|
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
||||||
ctxMenu.open(e, [
|
ctxMenu.open(e, [
|
||||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
|
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
|
||||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
|
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
|
||||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
|
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
|
||||||
])
|
])
|
||||||
@@ -223,12 +254,13 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
scrollContainerRef, onScrollTopChange,
|
scrollContainerRef, onScrollTopChange,
|
||||||
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
||||||
listImportLoading, listImportProvider, setListImportProvider,
|
listImportLoading, listImportProvider, setListImportProvider,
|
||||||
|
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||||
hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Plane, Save } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { airtrailApi } from '../../api/client'
|
||||||
|
import Section from './Section'
|
||||||
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
|
||||||
|
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
|
||||||
|
* connection layout: stacked fields, a toggle, then Save / Test-connection with
|
||||||
|
* a status badge. The key is stored encrypted and never prefilled.
|
||||||
|
*/
|
||||||
|
export default function AirTrailConnectionSection(): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||||
|
const [writeEnabled, setWriteEnabled] = useState(false)
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
airtrailApi
|
||||||
|
.getSettings()
|
||||||
|
.then(d => {
|
||||||
|
setUrl(d.url || '')
|
||||||
|
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||||
|
setWriteEnabled(!!d.writeEnabled)
|
||||||
|
setConnected(!!d.connected)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Send the key only when the user typed a new one — never prefilled, so a blank
|
||||||
|
// field means "keep the stored key".
|
||||||
|
const keyPayload = (): { apiKey?: string } => {
|
||||||
|
const k = apiKey.trim()
|
||||||
|
return k ? { apiKey: k } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
|
||||||
|
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
||||||
|
setConnected(!!status.connected)
|
||||||
|
setApiKey('')
|
||||||
|
if (d?.warning) toast.warning(d.warning)
|
||||||
|
else toast.success(t('settings.airtrail.toast.saved'))
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||||
|
setConnected(!!d.connected)
|
||||||
|
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
|
||||||
|
else toast.error(d.error || t('settings.airtrail.test.failed'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.airtrail.test.failed'))
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={t('settings.airtrail.title')} icon={Plane}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
placeholder="https://airtrail.example.com"
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={e => setApiKey(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading || !canSave}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> {t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing || loading || !url.trim()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plane className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{t('settings.airtrail.test.button')}
|
||||||
|
</button>
|
||||||
|
{connected ? (
|
||||||
|
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
|
{t('settings.airtrail.connected')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||||
|
{t('settings.airtrail.notConnected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -150,6 +150,22 @@ describe('DisplaySettingsTab', () => {
|
|||||||
expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit');
|
expect(updateSetting).toHaveBeenCalledWith('temperature_unit', 'fahrenheit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-DISPLAY-028: metric distance button is active by default', () => {
|
||||||
|
seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } });
|
||||||
|
render(<DisplaySettingsTab />);
|
||||||
|
const metricBtn = screen.getByText('km Metric').closest('button')!;
|
||||||
|
expect(metricBtn.style.border).toContain('var(--text-primary)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }), updateSetting });
|
||||||
|
render(<DisplaySettingsTab />);
|
||||||
|
await user.click(screen.getByText('mi Imperial'));
|
||||||
|
expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
|
it('FE-COMP-DISPLAY-020: clicking 24h time format calls updateSetting with 24h', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { useToast } from '../shared/Toast'
|
|||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import type { DistanceUnit } from '../../types'
|
||||||
|
|
||||||
export default function DisplaySettingsTab(): React.ReactElement {
|
export default function DisplaySettingsTab(): React.ReactElement {
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||||
|
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || 'metric')
|
||||||
const [langOpen, setLangOpen] = useState(false)
|
const [langOpen, setLangOpen] = useState(false)
|
||||||
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -28,6 +30,10 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
setTempUnit(settings.temperature_unit || 'celsius')
|
setTempUnit(settings.temperature_unit || 'celsius')
|
||||||
}, [settings.temperature_unit])
|
}, [settings.temperature_unit])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDistanceUnit(settings.distance_unit || 'metric')
|
||||||
|
}, [settings.distance_unit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.display')} icon={Palette}>
|
<Section title={t('settings.display')} icon={Palette}>
|
||||||
{/* Display currency */}
|
{/* Display currency */}
|
||||||
@@ -200,6 +206,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Distance */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.distance')}</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{([
|
||||||
|
{ value: 'metric', label: 'km Metric' },
|
||||||
|
{ value: 'imperial', label: 'mi Imperial' },
|
||||||
|
] as const).map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={async () => {
|
||||||
|
setDistanceUnit(opt.value)
|
||||||
|
try { await updateSetting('distance_unit', opt.value) }
|
||||||
|
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||||
|
border: distanceUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||||
|
background: distanceUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Time Format */}
|
{/* Time Format */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.timeFormat')}</label>
|
||||||
@@ -262,6 +299,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
|
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Explore places on the map (POI category pill) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.mapPoiPill')}</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[
|
||||||
|
{ value: true, label: t('settings.on') || 'On' },
|
||||||
|
{ value: false, label: t('settings.off') || 'Off' },
|
||||||
|
].map(opt => (
|
||||||
|
<button
|
||||||
|
key={String(opt.value)}
|
||||||
|
onClick={async () => {
|
||||||
|
try { await updateSetting('map_poi_pill_enabled', opt.value) }
|
||||||
|
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||||
|
border: (settings.map_poi_pill_enabled !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||||
|
background: (settings.map_poi_pill_enabled !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-1 text-content-faint">{t('settings.mapPoiPillHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
|
|||||||
import { authApi, oauthApi } from '../../api/client'
|
import { authApi, oauthApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||||
|
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PhotoProvidersSection />
|
<PhotoProvidersSection />
|
||||||
|
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||||
<McpTokenModals {...S} />
|
<McpTokenModals {...S} />
|
||||||
<OAuthClientModals {...S} />
|
<OAuthClientModals {...S} />
|
||||||
@@ -109,6 +111,7 @@ function useIntegrations() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||||
const mcpEnabled = addonEnabled('mcp')
|
const mcpEnabled = addonEnabled('mcp')
|
||||||
|
const airtrailEnabled = addonEnabled('airtrail')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAddons()
|
loadAddons()
|
||||||
@@ -289,7 +292,7 @@ function useIntegrations() {
|
|||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
|
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { MapView } from '../Map/MapView'
|
import { MapView } from '../Map/MapView'
|
||||||
import MapboxPreview from './MapboxPreview'
|
import GlMapPreview from './MapboxPreview'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
import ToggleSwitch from './ToggleSwitch'
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
import {
|
||||||
|
MAPBOX_DEFAULT_STYLE,
|
||||||
|
defaultStyleForProvider,
|
||||||
|
getStylePresets,
|
||||||
|
isOpenFreeMapStyle,
|
||||||
|
normalizeStyleForProvider,
|
||||||
|
type GlMapProvider,
|
||||||
|
} from '../Map/glProviders'
|
||||||
|
|
||||||
interface MapPreset {
|
interface MapPreset {
|
||||||
name: string
|
name: string
|
||||||
@@ -23,25 +31,6 @@ const MAP_PRESETS: MapPreset[] = [
|
|||||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface StylePreset {
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
tags: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAPBOX_STYLE_PRESETS: StylePreset[] = [
|
|
||||||
{ name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] },
|
|
||||||
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
|
|
||||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
|
|
||||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
|
|
||||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
|
|
||||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
|
|
||||||
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
|
|
||||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
|
|
||||||
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
|
|
||||||
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
||||||
// user scanning the list can spot 3D / Satellite / Apple-like styles.
|
// user scanning the list can spot 3D / Satellite / Apple-like styles.
|
||||||
const TAG_STYLES: Record<string, string> = {
|
const TAG_STYLES: Record<string, string> = {
|
||||||
@@ -59,6 +48,7 @@ const TAG_STYLES: Record<string, string> = {
|
|||||||
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
||||||
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||||
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||||
|
'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||||
}
|
}
|
||||||
|
|
||||||
function TagChip({ tag }: { tag: string }) {
|
function TagChip({ tag }: { tag: string }) {
|
||||||
@@ -70,10 +60,11 @@ function TagChip({ tag }: { tag: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const presets = getStylePresets(provider)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -84,7 +75,10 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
return () => document.removeEventListener('mousedown', onDoc)
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value)
|
const selected = presets.find(p => p.url === value)
|
||||||
|
const placeholder = provider === 'maplibre-gl'
|
||||||
|
? t('settings.mapOpenFreeMapStylePlaceholder')
|
||||||
|
: t('settings.mapStylePlaceholder')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
@@ -95,11 +89,11 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-slate-900 dark:text-white truncate">
|
<span className="text-slate-900 dark:text-white truncate">
|
||||||
{selected ? selected.name : t('settings.mapStylePlaceholder')}
|
{selected ? selected.name : placeholder}
|
||||||
</span>
|
</span>
|
||||||
{selected && (
|
{selected && (
|
||||||
<span className="flex items-center gap-1 flex-shrink-0">
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
{selected.tags.map(t => <TagChip key={t} tag={t} />)}
|
{(selected.tags || []).map(t => <TagChip key={t} tag={t} />)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,7 +101,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
|
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
|
||||||
{MAPBOX_STYLE_PRESETS.map(preset => {
|
{presets.map(preset => {
|
||||||
const isActive = preset.url === value
|
const isActive = preset.url === value
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +112,7 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 flex-wrap">
|
<span className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
|
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
|
||||||
{preset.tags.map(t => <TagChip key={t} tag={t} />)}
|
{(preset.tags || []).map(t => <TagChip key={t} tag={t} />)}
|
||||||
</span>
|
</span>
|
||||||
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
|
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -130,17 +124,34 @@ function StyleDropdown({ value, onChange }: { value: string; onChange: (v: strin
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Provider = 'leaflet' | 'mapbox-gl'
|
type Provider = 'leaflet' | GlMapProvider
|
||||||
|
|
||||||
|
function normalizeProvider(value: unknown): Provider {
|
||||||
|
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleForProvider(provider: Provider, style?: string | null): string {
|
||||||
|
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||||
|
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||||
|
return normalizeStyleForProvider(provider, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each GL provider has its own style slot, so toggling providers never clobbers the
|
||||||
|
// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style.
|
||||||
|
function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined {
|
||||||
|
return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style
|
||||||
|
}
|
||||||
|
|
||||||
export default function MapSettingsTab(): React.ReactElement {
|
export default function MapSettingsTab(): React.ReactElement {
|
||||||
const { settings, updateSettings } = useSettingsStore()
|
const { settings, updateSettings } = useSettingsStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const initialProvider = normalizeProvider(settings.map_provider)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
|
const [provider, setProvider] = useState<Provider>(initialProvider)
|
||||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||||
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
|
||||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||||
@@ -148,10 +159,11 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProvider((settings.map_provider as Provider) || 'leaflet')
|
const nextProvider = normalizeProvider(settings.map_provider)
|
||||||
|
setProvider(nextProvider)
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
setMapTileUrl(settings.map_tile_url || '')
|
||||||
setMapboxToken(settings.mapbox_access_token || '')
|
setMapboxToken(settings.mapbox_access_token || '')
|
||||||
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings)))
|
||||||
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||||
setMapboxQuality(settings.mapbox_quality_mode === true)
|
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||||
setDefaultLat(settings.default_lat || 48.8566)
|
setDefaultLat(settings.default_lat || 48.8566)
|
||||||
@@ -186,11 +198,15 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
const saveMapSettings = async (): Promise<void> => {
|
const saveMapSettings = async (): Promise<void> => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
|
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle)
|
||||||
|
setMapboxStyle(glStyle)
|
||||||
|
// Save into the active provider's own slot so the other provider's style survives.
|
||||||
|
const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
map_provider: provider,
|
map_provider: provider,
|
||||||
map_tile_url: mapTileUrl,
|
map_tile_url: mapTileUrl,
|
||||||
mapbox_access_token: mapboxToken,
|
mapbox_access_token: mapboxToken,
|
||||||
mapbox_style: mapboxStyle,
|
...stylePatch,
|
||||||
mapbox_3d_enabled: mapbox3d,
|
mapbox_3d_enabled: mapbox3d,
|
||||||
mapbox_quality_mode: mapboxQuality,
|
mapbox_quality_mode: mapboxQuality,
|
||||||
default_lat: parseFloat(String(defaultLat)),
|
default_lat: parseFloat(String(defaultLat)),
|
||||||
@@ -208,16 +224,20 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
// 3D is available on every style now — pure satellite uses the
|
// 3D is available on every style now — pure satellite uses the
|
||||||
// mapbox-streets-v8 tileset as a fallback building source.
|
// mapbox-streets-v8 tileset as a fallback building source.
|
||||||
const supports3d = true
|
const supports3d = true
|
||||||
|
const changeProvider = (nextProvider: Provider) => {
|
||||||
|
setProvider(nextProvider)
|
||||||
|
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.map')} icon={Map}>
|
<Section title={t('settings.map')} icon={Map}>
|
||||||
{/* Provider picker — big cards so the choice is obvious */}
|
{/* Provider picker — big cards so the choice is obvious */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setProvider('leaflet')}
|
onClick={() => changeProvider('leaflet')}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||||
provider === 'leaflet'
|
provider === 'leaflet'
|
||||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||||
@@ -232,7 +252,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setProvider('mapbox-gl')}
|
onClick={() => changeProvider('mapbox-gl')}
|
||||||
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||||
provider === 'mapbox-gl'
|
provider === 'mapbox-gl'
|
||||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||||
@@ -252,6 +272,24 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
{t('settings.mapExperimental')}
|
{t('settings.mapExperimental')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeProvider('maplibre-gl')}
|
||||||
|
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||||
|
provider === 'maplibre-gl'
|
||||||
|
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||||
|
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe2 size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-slate-900 dark:text-white">
|
||||||
|
<span className="sm:hidden">MapLibre</span>
|
||||||
|
<span className="hidden sm:inline">MapLibre GL</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2">
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
{t('settings.mapProviderHint')}
|
{t('settings.mapProviderHint')}
|
||||||
@@ -281,9 +319,10 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mapbox GL settings */}
|
{/* GL settings */}
|
||||||
{provider === 'mapbox-gl' && (
|
{provider !== 'leaflet' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{provider === 'mapbox-gl' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -300,24 +339,27 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
|
<StyleDropdown value={mapboxStyle} provider={provider} onChange={setMapboxStyle} />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={mapboxStyle}
|
value={mapboxStyle}
|
||||||
onChange={(e) => setMapboxStyle(e.target.value)}
|
onChange={(e) => setMapboxStyle(e.target.value)}
|
||||||
placeholder="mapbox://styles/mapbox/standard"
|
placeholder={defaultStyleForProvider(provider)}
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
{t('settings.mapStyleHint')}
|
{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{provider === 'mapbox-gl' && (
|
||||||
|
<>
|
||||||
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||||
supports3d
|
supports3d
|
||||||
? 'border-slate-200 dark:border-slate-700'
|
? 'border-slate-200 dark:border-slate-700'
|
||||||
@@ -354,6 +396,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||||
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -383,8 +427,9 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||||
{provider === 'mapbox-gl' ? (
|
{provider !== 'leaflet' ? (
|
||||||
<MapboxPreview
|
<GlMapPreview
|
||||||
|
provider={provider}
|
||||||
token={mapboxToken}
|
token={mapboxToken}
|
||||||
style={mapboxStyle}
|
style={mapboxStyle}
|
||||||
lat={parseFloat(String(defaultLat)) || 48.8566}
|
lat={parseFloat(String(defaultLat)) || 48.8566}
|
||||||
@@ -392,8 +437,8 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
// Zoom in close so the style's character (3D buildings,
|
// Zoom in close so the style's character (3D buildings,
|
||||||
// satellite texture, label density) is immediately visible.
|
// satellite texture, label density) is immediately visible.
|
||||||
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
|
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
|
||||||
enable3d={mapbox3d && supports3d}
|
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
|
||||||
quality={mapboxQuality}
|
quality={provider === 'mapbox-gl' && mapboxQuality}
|
||||||
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import mapboxgl from 'mapbox-gl'
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||||
|
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
token: string
|
provider?: GlMapProvider
|
||||||
|
token?: string
|
||||||
style: string
|
style: string
|
||||||
lat: number
|
lat: number
|
||||||
lng: number
|
lng: number
|
||||||
@@ -14,37 +18,44 @@ interface Props {
|
|||||||
onClick?: (latlng: { lat: number; lng: number }) => void
|
onClick?: (latlng: { lat: number; lng: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mapRef = useRef<any | null>(null)
|
||||||
const onClickRef = useRef(onClick)
|
const onClickRef = useRef(onClick)
|
||||||
onClickRef.current = onClick
|
onClickRef.current = onClick
|
||||||
|
const isMapLibre = provider === 'maplibre-gl'
|
||||||
|
const gl = (isMapLibre ? maplibregl : mapboxgl) as any
|
||||||
|
const glStyle = normalizeStyleForProvider(provider, style)
|
||||||
|
const enableMapbox3d = !isMapLibre && enable3d
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !token) return
|
if (!containerRef.current || (!isMapLibre && !token)) return
|
||||||
mapboxgl.accessToken = token
|
if (!isMapLibre) mapboxgl.accessToken = token
|
||||||
|
|
||||||
const map = new mapboxgl.Map({
|
const mapOptions: Record<string, unknown> = {
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style,
|
style: glStyle,
|
||||||
center: [lng, lat],
|
center: [lng, lat],
|
||||||
zoom,
|
zoom,
|
||||||
pitch: enable3d ? 45 : 0,
|
pitch: enableMapbox3d ? 45 : 0,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
antialias: quality,
|
antialias: quality,
|
||||||
projection: quality ? 'globe' : 'mercator',
|
}
|
||||||
})
|
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
|
||||||
|
|
||||||
|
const map = new gl.Map(mapOptions as any)
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
if (enable3d) {
|
if (enableMapbox3d) {
|
||||||
if (!isStandardFamily(style)) addTerrainAndSky(map)
|
if (!isStandardFamily(glStyle)) addTerrainAndSky(map)
|
||||||
if (supportsCustom3d(style)) {
|
if (supportsCustom3d(glStyle)) {
|
||||||
const dark = document.documentElement.classList.contains('dark')
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
addCustom3dBuildings(map, dark)
|
addCustom3dBuildings(map, dark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (style === 'mapbox://styles/mapbox/standard') {
|
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||||
try { map.setTerrain(null) } catch { /* noop */ }
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -57,7 +68,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d,
|
|||||||
try { map.remove() } catch { /* noop */ }
|
try { map.remove() } catch { /* noop */ }
|
||||||
mapRef.current = null
|
mapRef.current = null
|
||||||
}
|
}
|
||||||
}, [token, style, enable3d, quality])
|
}, [provider, token, glStyle, enableMapbox3d, quality])
|
||||||
|
|
||||||
// Recenter without rebuilding the map when lat/lng/zoom change externally
|
// Recenter without rebuilding the map when lat/lng/zoom change externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,7 +76,7 @@ export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d,
|
|||||||
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
|
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
|
||||||
}, [lat, lng, zoom])
|
}, [lat, lng, zoom])
|
||||||
|
|
||||||
if (!token) {
|
if (!isMapLibre && !token) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
|
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
Enter a Mapbox access token to preview
|
Enter a Mapbox access token to preview
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface CachedTripRow {
|
|||||||
export default function OfflineTab(): React.ReactElement {
|
export default function OfflineTab(): React.ReactElement {
|
||||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [failedCount, setFailedCount] = useState(0)
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [clearing, setClearing] = useState(false)
|
const [clearing, setClearing] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -28,11 +29,13 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [metas, pending] = await Promise.all([
|
const [metas, pending, failed] = await Promise.all([
|
||||||
offlineDb.syncMeta.toArray(),
|
offlineDb.syncMeta.toArray(),
|
||||||
mutationQueue.pendingCount(),
|
mutationQueue.pendingCount(),
|
||||||
|
mutationQueue.failedCount(),
|
||||||
])
|
])
|
||||||
setPendingCount(pending)
|
setPendingCount(pending)
|
||||||
|
setFailedCount(failed)
|
||||||
|
|
||||||
const result: CachedTripRow[] = []
|
const result: CachedTripRow[] = []
|
||||||
for (const meta of metas) {
|
for (const meta of metas) {
|
||||||
@@ -85,6 +88,7 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<Stat label="Cached trips" value={rows.length} />
|
<Stat label="Cached trips" value={rows.length} />
|
||||||
<Stat label="Pending changes" value={pendingCount} />
|
<Stat label="Pending changes" value={pendingCount} />
|
||||||
|
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -165,13 +169,14 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: number }) {
|
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-edge bg-surface-secondary" style={{
|
<div className="border border-edge bg-surface-secondary" style={{
|
||||||
padding: '8px 14px', borderRadius: 8,
|
padding: '8px 14px', borderRadius: 8,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
}}>
|
}}>
|
||||||
<div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
|
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||||
|
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||||
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onToggle}
|
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||||
|
|||||||
@@ -62,16 +62,17 @@ function CTALink({
|
|||||||
if (notice.cta.kind === 'nav') {
|
if (notice.cta.kind === 'nav') {
|
||||||
navigate(notice.cta.href);
|
navigate(notice.cta.href);
|
||||||
if (notice.dismissible) onDismiss();
|
if (notice.dismissible) onDismiss();
|
||||||
|
} else if (notice.cta.kind === 'link') {
|
||||||
|
window.open(notice.cta.href, '_blank', 'noopener,noreferrer');
|
||||||
} else {
|
} else {
|
||||||
runNoticeAction(notice.cta.actionId, { navigate });
|
runNoticeAction(notice.cta.actionId, { navigate });
|
||||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
if (notice.cta.dismissOnAction !== false) onDismiss();
|
||||||
if (actionCta.dismissOnAction !== false) onDismiss();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!notice.cta) return null;
|
if (!notice.cta) return null;
|
||||||
|
|
||||||
if (notice.cta.kind === 'nav') {
|
if (notice.cta.kind === 'nav' || notice.cta.kind === 'link') {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={notice.cta.href}
|
href={notice.cta.href}
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import React, { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||||
import { ModalRenderer } from './SystemNoticeModal.js';
|
import { ModalRenderer } from './SystemNoticeModal.js';
|
||||||
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
|
import { BannerRenderer, ToastRenderer } from './SystemNoticeBanner.js';
|
||||||
|
|
||||||
|
// Mobile breakpoint matches the modal sheet's (max-width: 639px).
|
||||||
|
function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState(
|
||||||
|
() => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false)
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia?.('(max-width: 639px)');
|
||||||
|
if (!mq) return;
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
export function SystemNoticeHost() {
|
export function SystemNoticeHost() {
|
||||||
const { notices, loaded } = useSystemNoticeStore();
|
const { notices, loaded } = useSystemNoticeStore();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
|
// Notices are fetched by authStore after login (see App.tsx / authStore modification).
|
||||||
// Cold-session fetch (page reload with valid session) is triggered here:
|
// Cold-session fetch (page reload with valid session) is triggered here:
|
||||||
@@ -17,9 +33,12 @@ export function SystemNoticeHost() {
|
|||||||
|
|
||||||
if (!loaded) return null;
|
if (!loaded) return null;
|
||||||
|
|
||||||
const modals = notices.filter(n => n.display === 'modal');
|
// desktopOnly notices (e.g. the thank-you/support modal) are hidden on mobile.
|
||||||
const banners = notices.filter(n => n.display === 'banner');
|
const visible = isMobile ? notices.filter(n => !n.desktopOnly) : notices;
|
||||||
const toasts = notices.filter(n => n.display === 'toast');
|
|
||||||
|
const modals = visible.filter(n => n.display === 'modal');
|
||||||
|
const banners = visible.filter(n => n.display === 'banner');
|
||||||
|
const toasts = visible.filter(n => n.display === 'toast');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight, Coffee } from 'lucide-react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
@@ -36,6 +36,33 @@ const SEVERITY_ACCENT: Record<string, string> = {
|
|||||||
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
|
critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Real brand marks (simple-icons single-path logos) for the support buttons, so the
|
||||||
|
// Buy Me a Coffee / Ko-fi buttons carry their actual logo instead of a generic
|
||||||
|
// lucide glyph. Tinted via currentColor.
|
||||||
|
const BRAND_ICON_PATHS: Record<string, string> = {
|
||||||
|
buymeacoffee:
|
||||||
|
'M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z',
|
||||||
|
kofi:
|
||||||
|
'M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298',
|
||||||
|
};
|
||||||
|
|
||||||
|
function brandForHref(href?: string): string | null {
|
||||||
|
if (!href) return null;
|
||||||
|
if (href.includes('buymeacoffee')) return 'buymeacoffee';
|
||||||
|
if (href.includes('ko-fi.com') || href.includes('kofi')) return 'kofi';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandIcon({ brand, size = 18, className }: { brand: string; size?: number; className?: string }) {
|
||||||
|
const d = BRAND_ICON_PATHS[brand];
|
||||||
|
if (!d) return null;
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" className={className} aria-hidden="true">
|
||||||
|
<path d={d} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
notices: SystemNoticeDTO[];
|
notices: SystemNoticeDTO[];
|
||||||
}
|
}
|
||||||
@@ -46,12 +73,14 @@ interface ContentProps {
|
|||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
ctaLabel: string | null;
|
ctaLabel: string | null;
|
||||||
|
secondaryCtaLabel: string | null;
|
||||||
titleId: string;
|
titleId: string;
|
||||||
bodyId: string;
|
bodyId: string;
|
||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onDismissAll: () => void;
|
onDismissAll: () => void;
|
||||||
onCTA: () => void;
|
onCTA: () => void;
|
||||||
|
onSecondaryCTA: () => void;
|
||||||
// Pager
|
// Pager
|
||||||
total: number;
|
total: number;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
@@ -61,7 +90,7 @@ interface ContentProps {
|
|||||||
onGoto: (i: number) => void;
|
onGoto: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isLastPage = total <= 1 || currentPage === total - 1;
|
const isLastPage = total <= 1 || currentPage === total - 1;
|
||||||
|
|
||||||
@@ -70,6 +99,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
|
? ((LucideIcons as Record<string, unknown>)[notice.icon] as React.ElementType) ?? DefaultIcon
|
||||||
: DefaultIcon;
|
: DefaultIcon;
|
||||||
|
|
||||||
|
// Real brand logo for each support button, detected from the link target.
|
||||||
|
const primaryBrand = notice.cta?.kind === 'link' ? brandForHref(notice.cta.href) : null;
|
||||||
|
const secondaryBrand = notice.secondaryCta?.kind === 'link' ? brandForHref(notice.secondaryCta.href) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
||||||
{/* Dismiss X button — only on last page so users read all notices */}
|
{/* Dismiss X button — only on last page so users read all notices */}
|
||||||
@@ -104,17 +137,9 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
|
|
||||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||||
{notice.icon === 'Heart' && !notice.media && (
|
{notice.icon === 'Heart' && !notice.media && (
|
||||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-6 text-center">
|
||||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||||
<div className="relative flex items-center justify-center gap-3">
|
<h2 id={titleId} className="relative text-xl font-bold text-white leading-tight">{title}</h2>
|
||||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
|
||||||
<LucideIcon size={20} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
|
||||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -197,24 +222,27 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Highlights */}
|
{/* Highlights — compact pills */}
|
||||||
{notice.highlights && notice.highlights.length > 0 && (
|
{notice.highlights && notice.highlights.length > 0 && (
|
||||||
<ul className="mx-auto mb-4 space-y-2">
|
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
||||||
{notice.highlights.map((h, i) => {
|
{notice.highlights.map((h, i) => {
|
||||||
const HIcon: React.ElementType | null = h.iconName
|
const HIcon: React.ElementType | null = h.iconName
|
||||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 dark:bg-slate-800 px-3 py-1 text-xs font-medium text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
{HIcon
|
{HIcon
|
||||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
? <HIcon size={13} className="text-indigo-500 dark:text-indigo-400 shrink-0" />
|
||||||
: <span className="text-blue-500 shrink-0">✓</span>
|
: <span className="text-indigo-500 shrink-0">✓</span>
|
||||||
}
|
}
|
||||||
{t(h.labelKey)}
|
{t(h.labelKey)}
|
||||||
</li>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,16 +298,37 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA + dismiss link */}
|
{/* CTA(s) + dismiss link */}
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{ctaLabel && isLastPage ? (
|
{ctaLabel && isLastPage ? (
|
||||||
<button
|
<div className="flex w-full flex-col sm:flex-row gap-2.5">
|
||||||
id={`notice-cta-${notice.id}`}
|
<button
|
||||||
onClick={onCTA}
|
id={`notice-cta-${notice.id}`}
|
||||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
onClick={onCTA}
|
||||||
>
|
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
|
||||||
{ctaLabel}
|
notice.cta?.kind === 'link'
|
||||||
</button>
|
? 'bg-[#FFDD00] text-[#0D0C22] hover:brightness-95'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{primaryBrand ? <BrandIcon brand={primaryBrand} size={18} /> : (notice.cta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
|
||||||
|
{ctaLabel}
|
||||||
|
</button>
|
||||||
|
{secondaryCtaLabel && (
|
||||||
|
<button
|
||||||
|
id={`notice-cta2-${notice.id}`}
|
||||||
|
onClick={onSecondaryCTA}
|
||||||
|
className={`flex-1 h-11 inline-flex items-center justify-center gap-2 rounded-lg font-semibold shadow-sm transition active:scale-[0.98] ${
|
||||||
|
notice.secondaryCta?.kind === 'link'
|
||||||
|
? 'bg-[#FF5E5B] text-white hover:brightness-95'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{secondaryBrand ? <BrandIcon brand={secondaryBrand} size={18} /> : (notice.secondaryCta?.kind === 'link' && <Coffee size={17} aria-hidden="true" />)}
|
||||||
|
{secondaryCtaLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (notice.dismissible || isLastPage) && (
|
) : (notice.dismissible || isLastPage) && (
|
||||||
<button
|
<button
|
||||||
id={`notice-cta-${notice.id}`}
|
id={`notice-cta-${notice.id}`}
|
||||||
@@ -289,14 +338,6 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
{t('common.ok')}
|
{t('common.ok')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{notice.dismissible && isLastPage && ctaLabel && (
|
|
||||||
<button
|
|
||||||
onClick={onDismiss}
|
|
||||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
|
|
||||||
>
|
|
||||||
Not now
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -510,21 +551,22 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
|||||||
notices.forEach(n => dismiss(n.id));
|
notices.forEach(n => dismiss(n.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCTA() {
|
function runCta(cta: SystemNoticeDTO['cta']) {
|
||||||
if (!notice) return;
|
if (!cta) { handleDismissAll(); return; }
|
||||||
if (!notice.cta) {
|
if (cta.kind === 'nav') {
|
||||||
handleDismissAll();
|
navigate(cta.href);
|
||||||
return;
|
if (notice?.dismissible !== false) handleDismissAll();
|
||||||
}
|
} else if (cta.kind === 'link') {
|
||||||
if (notice.cta.kind === 'nav') {
|
// External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the
|
||||||
navigate(notice.cta.href);
|
// notice open so the user can use the other button too.
|
||||||
if (notice.dismissible !== false) handleDismissAll();
|
window.open(cta.href, '_blank', 'noopener,noreferrer');
|
||||||
} else {
|
} else {
|
||||||
runNoticeAction(notice.cta.actionId, { navigate });
|
runNoticeAction(cta.actionId, { navigate });
|
||||||
const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
|
if (cta.dismissOnAction !== false) handleDismissAll();
|
||||||
if (actionCta.dismissOnAction !== false) handleDismissAll();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function handleCTA() { runCta(notice?.cta); }
|
||||||
|
function handleSecondaryCTA() { runCta(notice?.secondaryCta); }
|
||||||
|
|
||||||
function animatedDismissAll() {
|
function animatedDismissAll() {
|
||||||
const sheet = sheetRef.current;
|
const sheet = sheetRef.current;
|
||||||
@@ -584,7 +626,7 @@ function useSystemNoticeModal(notices: SystemNoticeDTO[]) {
|
|||||||
notice, canPage, isLastPage, language, t, dur, ease,
|
notice, canPage, isLastPage, language, t, dur, ease,
|
||||||
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
|
touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef,
|
||||||
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
|
stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef,
|
||||||
announceIndex, handleDismiss, handleDismissAll, handleCTA, animatedDismissAll,
|
announceIndex, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll,
|
||||||
handlePrev, handleNext, handleGoto,
|
handlePrev, handleNext, handleGoto,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -593,7 +635,7 @@ type NoticeState = ReturnType<typeof useSystemNoticeModal>;
|
|||||||
|
|
||||||
// Build the NoticeContent props for a given notice + pager slot index.
|
// Build the NoticeContent props for a given notice + pager slot index.
|
||||||
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||||
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handlePrev, handleNext, handleGoto } = S;
|
const { t, isDark, canPage, notices, handleDismiss, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S;
|
||||||
const rawBody = t(n.bodyKey);
|
const rawBody = t(n.bodyKey);
|
||||||
const body = n.bodyParams
|
const body = n.bodyParams
|
||||||
? Object.entries(n.bodyParams).reduce(
|
? Object.entries(n.bodyParams).reduce(
|
||||||
@@ -606,12 +648,14 @@ function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number):
|
|||||||
title: t(n.titleKey),
|
title: t(n.titleKey),
|
||||||
body,
|
body,
|
||||||
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
||||||
|
secondaryCtaLabel: n.secondaryCta ? t(n.secondaryCta.labelKey) : null,
|
||||||
titleId: `notice-title-${n.id}`,
|
titleId: `notice-title-${n.id}`,
|
||||||
bodyId: `notice-body-${n.id}`,
|
bodyId: `notice-body-${n.id}`,
|
||||||
isDark,
|
isDark,
|
||||||
onDismiss: handleDismiss,
|
onDismiss: handleDismiss,
|
||||||
onDismissAll: handleDismissAll,
|
onDismissAll: handleDismissAll,
|
||||||
onCTA: handleCTA,
|
onCTA: handleCTA,
|
||||||
|
onSecondaryCTA: handleSecondaryCTA,
|
||||||
total: notices.length,
|
total: notices.length,
|
||||||
currentPage: slotIdx,
|
currentPage: slotIdx,
|
||||||
canPage,
|
canPage,
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
|||||||
const [desc, setDesc] = useState(item.description || '')
|
const [desc, setDesc] = useState(item.description || '')
|
||||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||||
const [category, setCategory] = useState(item.category || '')
|
const [category, setCategory] = useState(item.category || '')
|
||||||
|
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||||
const [priority, setPriority] = useState(item.priority || 0)
|
const [priority, setPriority] = useState(item.priority || 0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>{t('todo.detail.category')}</label>
|
<label className={labelClass}>{t('todo.detail.category')}</label>
|
||||||
<CustomSelect
|
{addingCategory ? (
|
||||||
value={category}
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
onChange={v => setCategory(String(v))}
|
<input
|
||||||
options={[
|
autoFocus
|
||||||
{ value: '', label: t('todo.noCategory') },
|
value={category}
|
||||||
...categories.map(c => ({
|
onChange={e => setCategory(e.target.value)}
|
||||||
value: c,
|
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||||
label: c,
|
placeholder={t('todo.newCategory')}
|
||||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
})),
|
/>
|
||||||
]}
|
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
||||||
placeholder={t('todo.noCategory')}
|
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
||||||
size="sm"
|
<Check size={14} />
|
||||||
disabled={!canEdit}
|
</button>
|
||||||
/>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={category}
|
||||||
|
onChange={v => setCategory(String(v))}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('todo.noCategory') },
|
||||||
|
...categories.map(c => ({
|
||||||
|
value: c, label: c,
|
||||||
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||||
|
})),
|
||||||
|
...(category && !categories.includes(category) ? [{
|
||||||
|
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
||||||
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
||||||
|
}] : []),
|
||||||
|
]}
|
||||||
|
placeholder={t('todo.noCategory')}
|
||||||
|
size="sm"
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
||||||
|
title={t('todo.newCategory')}
|
||||||
|
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due date */}
|
{/* Due date */}
|
||||||
|
|||||||
@@ -288,4 +288,26 @@ describe('TripFormModal', () => {
|
|||||||
await user.click(submitBtn.closest('button')!);
|
await user.click(submitBtn.closest('button')!);
|
||||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||||
|
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||||
|
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||||
|
expect(dayInput).toBeInTheDocument();
|
||||||
|
expect(dayInput.value).toBe('7');
|
||||||
|
fireEvent.change(dayInput, { target: { value: '' } });
|
||||||
|
expect(dayInput.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||||
|
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||||
|
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||||
|
fireEvent.change(dayInput, { target: { value: '' } });
|
||||||
|
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||||
|
await user.click(submitBtn.closest('button')!);
|
||||||
|
await screen.findByText('Number of days is required');
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
reminder_days: 0 as number,
|
reminder_days: 0 as number,
|
||||||
day_count: 7,
|
day_count: 7 as number | '',
|
||||||
})
|
})
|
||||||
const [customReminder, setCustomReminder] = useState(false)
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -100,6 +100,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||||
setError(t('dashboard.endDateError')); return
|
setError(t('dashboard.endDateError')); return
|
||||||
}
|
}
|
||||||
|
if (!formData.start_date && !formData.end_date) {
|
||||||
|
const dc = Number(formData.day_count)
|
||||||
|
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||||
|
setError(t('dashboard.dayCountRequired')); return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await onSave({
|
const result = await onSave({
|
||||||
@@ -108,7 +114,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
reminder_days: formData.reminder_days,
|
reminder_days: formData.reminder_days,
|
||||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
||||||
})
|
})
|
||||||
const createdTrip = result ? result.trip : undefined
|
const createdTrip = result ? result.trip : undefined
|
||||||
// Add selected members for newly created trips
|
// Add selected members for newly created trips
|
||||||
@@ -320,7 +326,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
{t('dashboard.dayCount')}
|
{t('dashboard.dayCount')}
|
||||||
</label>
|
</label>
|
||||||
<input type="number" min={1} max={365} value={formData.day_count}
|
<input type="number" min={1} max={365} value={formData.day_count}
|
||||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
onChange={e => {
|
||||||
|
const raw = e.target.value
|
||||||
|
if (raw === '') { update('day_count', ''); return }
|
||||||
|
const n = Math.floor(Number(raw))
|
||||||
|
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||||
|
}}
|
||||||
className={inputCls} />
|
className={inputCls} />
|
||||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
<VacayMonthCard
|
<VacayMonthCard
|
||||||
key={i}
|
key={i}
|
||||||
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating toolbar */}
|
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
|
||||||
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompanyMode(false)}
|
onClick={() => setCompanyMode(false)}
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export function ToastContainer() {
|
|||||||
`}</style>
|
`}</style>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
|
||||||
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
|
||||||
|
// blur) so error toasts paint on top and stay legible instead of blurred behind.
|
||||||
|
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
|
||||||
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
|
||||||
}}>
|
}}>
|
||||||
{toasts.map(toast => (
|
{toasts.map(toast => (
|
||||||
|
|||||||
+137
-3
@@ -27,6 +27,12 @@ export interface QueuedMutation {
|
|||||||
tempId?: number;
|
tempId?: number;
|
||||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||||
entityId?: number;
|
entityId?: number;
|
||||||
|
/**
|
||||||
|
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
|
||||||
|
* the temp id of the target. The url carries an `{id}` placeholder that the
|
||||||
|
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
|
||||||
|
*/
|
||||||
|
tempEntityId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncMeta {
|
export interface SyncMeta {
|
||||||
@@ -41,13 +47,48 @@ export interface SyncMeta {
|
|||||||
export interface BlobCacheEntry {
|
export interface BlobCacheEntry {
|
||||||
/** Relative URL, e.g. "/api/files/42/download" */
|
/** Relative URL, e.g. "/api/files/42/download" */
|
||||||
url: string;
|
url: string;
|
||||||
|
/**
|
||||||
|
* Trip this blob belongs to, so it is evicted together with the trip in
|
||||||
|
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
|
||||||
|
*/
|
||||||
|
tripId: number;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
|
/** Byte size captured at insert time — Blob.size is not reliably preserved
|
||||||
|
* across IndexedDB round-trips, so the LRU budget reads this instead. */
|
||||||
|
bytes: number;
|
||||||
mime: string;
|
mime: string;
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offline DB is scoped per user so that one account can never read another
|
||||||
|
* account's cached data on a shared device. Anonymous (logged-out) state uses
|
||||||
|
* the base name; a logged-in user uses `trek-offline-u<userId>`.
|
||||||
|
*/
|
||||||
|
const ANON_DB_NAME = 'trek-offline';
|
||||||
|
|
||||||
|
function userDbName(userId: number | string): string {
|
||||||
|
return `trek-offline-u${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort read of the persisted auth snapshot so the very first DB opened on
|
||||||
|
* app load (before loadUser resolves) is already the correct per-user one — the
|
||||||
|
* PWA can render cached data offline without leaking across users.
|
||||||
|
*/
|
||||||
|
function initialDbName(): string {
|
||||||
|
try {
|
||||||
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
|
||||||
|
if (!raw) return ANON_DB_NAME;
|
||||||
|
const id = JSON.parse(raw)?.state?.user?.id;
|
||||||
|
return id != null ? userDbName(id) : ANON_DB_NAME;
|
||||||
|
} catch {
|
||||||
|
return ANON_DB_NAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TrekOfflineDb extends Dexie {
|
class TrekOfflineDb extends Dexie {
|
||||||
trips!: Table<Trip, number>;
|
trips!: Table<Trip, number>;
|
||||||
days!: Table<Day, number>;
|
days!: Table<Day, number>;
|
||||||
@@ -65,8 +106,8 @@ class TrekOfflineDb extends Dexie {
|
|||||||
syncMeta!: Table<SyncMeta, number>;
|
syncMeta!: Table<SyncMeta, number>;
|
||||||
blobCache!: Table<BlobCacheEntry, string>;
|
blobCache!: Table<BlobCacheEntry, string>;
|
||||||
|
|
||||||
constructor() {
|
constructor(name: string = ANON_DB_NAME) {
|
||||||
super('trek-offline');
|
super(name);
|
||||||
|
|
||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
trips: 'id',
|
trips: 'id',
|
||||||
@@ -88,10 +129,67 @@ class TrekOfflineDb extends Dexie {
|
|||||||
tags: 'id',
|
tags: 'id',
|
||||||
categories: 'id',
|
categories: 'id',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v3: scope the blob cache by trip so it can be evicted with the trip and
|
||||||
|
// bounded by an LRU budget (see enforceBlobBudget).
|
||||||
|
this.version(3).stores({
|
||||||
|
blobCache: 'url, cachedAt, tripId',
|
||||||
|
}).upgrade(async (tx) => {
|
||||||
|
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
|
||||||
|
if (row.tripId == null) row.tripId = -1;
|
||||||
|
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const offlineDb = new TrekOfflineDb();
|
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
||||||
|
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
|
||||||
|
// import it directly, while every access forwards to the current connection.
|
||||||
|
let _db = new TrekOfflineDb(initialDbName());
|
||||||
|
|
||||||
|
export const offlineDb = new Proxy({} as TrekOfflineDb, {
|
||||||
|
get(_target, prop) {
|
||||||
|
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
||||||
|
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
|
||||||
|
},
|
||||||
|
set(_target, prop, value) {
|
||||||
|
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}) as TrekOfflineDb;
|
||||||
|
|
||||||
|
async function switchTo(name: string): Promise<void> {
|
||||||
|
if (_db.name === name) {
|
||||||
|
if (!_db.isOpen()) await _db.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_db.isOpen()) _db.close();
|
||||||
|
_db = new TrekOfflineDb(name);
|
||||||
|
await _db.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the offline DB at a specific user's scoped database (call on login). */
|
||||||
|
export async function reopenForUser(userId: number | string): Promise<void> {
|
||||||
|
await switchTo(userDbName(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the offline DB at the anonymous database (call on logout). */
|
||||||
|
export async function reopenAnonymous(): Promise<void> {
|
||||||
|
await switchTo(ANON_DB_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the current user's scoped database entirely and return to the anonymous
|
||||||
|
* DB. Used on logout so no trace of the account's data remains on the device.
|
||||||
|
*/
|
||||||
|
export async function deleteCurrentUserDb(): Promise<void> {
|
||||||
|
if (_db.name !== ANON_DB_NAME) {
|
||||||
|
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
|
||||||
|
}
|
||||||
|
_db = new TrekOfflineDb(ANON_DB_NAME);
|
||||||
|
await _db.open();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -166,6 +264,40 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bounds for the offline file-blob cache. Kept conservative so trip
|
||||||
|
* documents never starve the map-tile cache (sized at MAX_TILES in
|
||||||
|
* tilePrefetcher.ts) for the origin's storage quota.
|
||||||
|
*/
|
||||||
|
export const BLOB_CACHE_MAX_ENTRIES = 200;
|
||||||
|
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
|
||||||
|
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
|
||||||
|
* is a reasonable proxy for access for write-once document blobs.
|
||||||
|
*/
|
||||||
|
export async function enforceBlobBudget(
|
||||||
|
maxCount = BLOB_CACHE_MAX_ENTRIES,
|
||||||
|
maxBytes = BLOB_CACHE_MAX_BYTES,
|
||||||
|
): Promise<void> {
|
||||||
|
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
|
||||||
|
let count = entries.length;
|
||||||
|
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
|
||||||
|
if (count <= maxCount && totalBytes <= maxBytes) return;
|
||||||
|
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if (count <= maxCount && totalBytes <= maxBytes) break;
|
||||||
|
toDelete.push(e.url);
|
||||||
|
totalBytes -= e.bytes ?? 0;
|
||||||
|
count -= 1;
|
||||||
|
}
|
||||||
|
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||||
@@ -184,6 +316,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
|||||||
offlineDb.tripMembers,
|
offlineDb.tripMembers,
|
||||||
offlineDb.mutationQueue,
|
offlineDb.mutationQueue,
|
||||||
offlineDb.syncMeta,
|
offlineDb.syncMeta,
|
||||||
|
offlineDb.blobCache,
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||||
@@ -197,6 +330,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
|||||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||||
|
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Remove the trip row itself outside the transaction since it's a separate table
|
// Remove the trip row itself outside the transaction since it's a separate table
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { airtrailApi } from '../api/client'
|
||||||
|
import { useAddonStore } from '../store/addonStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves whether the current user can use AirTrail in a trip: the addon must
|
||||||
|
* be enabled globally AND the user must have a working connection. Drives the
|
||||||
|
* "AirTrail Import/Sync" button visibility in the Transport panel.
|
||||||
|
*/
|
||||||
|
export function useAirtrailConnection() {
|
||||||
|
const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail'))
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!airtrailEnabled) {
|
||||||
|
setConnected(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
airtrailApi
|
||||||
|
.status()
|
||||||
|
.then(d => { if (!cancelled) setConnected(!!d.connected) })
|
||||||
|
.catch(() => { if (!cancelled) setConnected(false) })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [airtrailEnabled])
|
||||||
|
|
||||||
|
return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading }
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||||
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
* display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
|
||||||
* for the dashboard widget) for the given base and caches per base in memory +
|
* for the dashboard widget) for the given base and caches per base in memory +
|
||||||
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
||||||
* currency C converts to base as `amount / rates[C]`.
|
* currency C converts to base as `amount / rates[C]`.
|
||||||
@@ -33,14 +33,19 @@ export function useExchangeRates(base: string) {
|
|||||||
if (cached) setRates(cached.rates)
|
if (cached) setRates(cached.rates)
|
||||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((d: { rates?: Record<string, number> }) => {
|
.then((d: Array<{ quote?: string; rate?: number }>) => {
|
||||||
if (cancelled || !d?.rates) return
|
if (cancelled || !Array.isArray(d)) return
|
||||||
const entry = { rates: d.rates, ts: Date.now() }
|
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
||||||
|
const rates: Record<string, number> = { [upper]: 1 }
|
||||||
|
for (const r of d) {
|
||||||
|
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
|
||||||
|
}
|
||||||
|
const entry = { rates, ts: Date.now() }
|
||||||
mem.set(upper, entry)
|
mem.set(upper, entry)
|
||||||
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
||||||
setRates(d.rates)
|
setRates(rates)
|
||||||
})
|
})
|
||||||
.catch(() => { /* offline → keep cached/identity */ })
|
.catch(() => { /* offline → keep cached/identity */ })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
|
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
|
||||||
|
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||||
|
import { getDayBookendHotels } from '../utils/dayOrder'
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult, Accommodation } from '../types'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
|
||||||
|
|
||||||
|
const NO_ACCOMMODATIONS: Accommodation[] = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||||
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||||
*/
|
*/
|
||||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = NO_ACCOMMODATIONS) {
|
||||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
const routeAbortRef = useRef<AbortController | null>(null)
|
const routeAbortRef = useRef<AbortController | null>(null)
|
||||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||||
|
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
|
||||||
|
// hotel) unless the user turned the setting off — same gate as the sidebar.
|
||||||
|
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
|
||||||
|
// Recompute when the user flips km↔mi so leg distances (formatted at compute time)
|
||||||
|
// refresh instead of showing stale cached text (#1300).
|
||||||
|
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit)
|
||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
@@ -53,37 +64,94 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
return pos != null
|
return pos != null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build a unified list of places + transports sorted by effective position,
|
// Build a unified list of places + transports sorted by effective position.
|
||||||
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
|
type Entry =
|
||||||
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
|
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||||
const entries: (Entry & { pos: number })[] = [
|
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
|
||||||
|
const entries: Entry[] = [
|
||||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||||
})),
|
})),
|
||||||
...dayTransports.map(r => ({
|
...dayTransports.map(r => {
|
||||||
kind: 'transport' as const,
|
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
||||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
return {
|
||||||
})),
|
kind: 'transport' as const,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||||
|
}
|
||||||
|
}),
|
||||||
].sort((a, b) => a.pos - b.pos)
|
].sort((a, b) => a.pos - b.pos)
|
||||||
|
|
||||||
// Group consecutive located places into runs, resetting whenever a transport
|
// Group located places into driving runs.
|
||||||
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
// - A transport WITH a location anchors the route to its departure point (you
|
||||||
|
// travel there), then breaks the run (you don't drive the flight/train); its
|
||||||
|
// arrival point starts the next run.
|
||||||
|
// - A transport WITHOUT a location is ignored entirely — the places around it
|
||||||
|
// connect directly, as if the booking weren't there.
|
||||||
const runs: { lat: number; lng: number }[][] = []
|
const runs: { lat: number; lng: number }[][] = []
|
||||||
let currentRun: { lat: number; lng: number }[] = []
|
let currentRun: { lat: number; lng: number }[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind === 'place') {
|
if (entry.kind === 'place') {
|
||||||
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||||
} else {
|
} else if (entry.from || entry.to) {
|
||||||
|
if (entry.from) currentRun.push(entry.from)
|
||||||
if (currentRun.length >= 2) runs.push(currentRun)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
currentRun = []
|
currentRun = []
|
||||||
|
if (entry.to) currentRun.push(entry.to)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentRun.length >= 2) runs.push(currentRun)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
|
|
||||||
const straightLines = (): [number, number][][] =>
|
// Bookend the route with the day's accommodation: a hotel → first-stop run and
|
||||||
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
||||||
|
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
|
||||||
|
// transfer day) and already filters to accommodations that have coordinates.
|
||||||
|
const day = allDays.find(d => d.id === dayId)
|
||||||
|
const bookends = day && optimizeFromAccommodation !== false
|
||||||
|
? getDayBookendHotels(day, allDays, accommodations)
|
||||||
|
: null
|
||||||
|
const flatPts: { lat: number; lng: number }[] = []
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
|
||||||
|
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
|
||||||
|
}
|
||||||
|
const hotelPt = (a?: Accommodation) =>
|
||||||
|
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
|
||||||
|
// Only draw a hotel bookend when the leg is real. A hotel → first-stop leg holds
|
||||||
|
// if the first stop is a place, or if you actually slept in that hotel last night;
|
||||||
|
// on a day-1 arrival the morning hotel is just a check-in fallback and the first
|
||||||
|
// waypoint is the transport's departure point, so [hotel → departure] is dropped
|
||||||
|
// (#1321). Symmetrically, [last-stop → hotel] is dropped when you leave on a transport
|
||||||
|
// in the evening and don't sleep in that hotel tonight.
|
||||||
|
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
||||||
|
const firstStop = entries.find(contributes)
|
||||||
|
const lastStop = [...entries].reverse().find(contributes)
|
||||||
|
const drawMorning = firstStop?.kind === 'place' || !!bookends?.morningIsSleptHere
|
||||||
|
const drawEvening = lastStop?.kind === 'place' || !!bookends?.eveningIsOvernight
|
||||||
|
const runsWithHotel = withHotelBookends(
|
||||||
|
runs,
|
||||||
|
flatPts[0],
|
||||||
|
flatPts[flatPts.length - 1],
|
||||||
|
drawMorning ? hotelPt(bookends?.morning) : null,
|
||||||
|
drawEvening ? hotelPt(bookends?.evening) : null,
|
||||||
|
)
|
||||||
|
|
||||||
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
// Transfer day with no activities: you check out of one accommodation and into
|
||||||
|
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
||||||
|
// Draw the hotel → hotel transfer directly. Gated on both bookends being real
|
||||||
|
// (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two
|
||||||
|
// hotels being distinct, so an ordinary same-hotel rest day still draws nothing.
|
||||||
|
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
||||||
|
const m = hotelPt(bookends?.morning)
|
||||||
|
const e = hotelPt(bookends?.evening)
|
||||||
|
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||||
|
}
|
||||||
|
|
||||||
|
const straightLines = (): [number, number][][] =>
|
||||||
|
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
|
||||||
|
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
|
|
||||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
// OSRM road geometry.
|
// OSRM road geometry.
|
||||||
@@ -94,7 +162,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
try {
|
try {
|
||||||
const polylines: [number, number][][] = []
|
const polylines: [number, number][][] = []
|
||||||
const allLegs: RouteSegment[] = []
|
const allLegs: RouteSegment[] = []
|
||||||
for (const run of runs) {
|
for (const run of runsWithHotel) {
|
||||||
try {
|
try {
|
||||||
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||||
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
@@ -110,7 +178,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||||
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
||||||
}
|
}
|
||||||
}, [enabled, profile])
|
}, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
|
||||||
|
|
||||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||||
@@ -120,7 +188,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
||||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
|
// Include endpoints so adding/moving a departure/arrival location re-routes.
|
||||||
|
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
|
||||||
|
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
|
||||||
})
|
})
|
||||||
.sort()
|
.sort()
|
||||||
.join('|')
|
.join('|')
|
||||||
@@ -132,7 +202,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: Tran
|
|||||||
ko: () => import('@trek/shared/i18n/ko'),
|
ko: () => import('@trek/shared/i18n/ko'),
|
||||||
uk: () => import('@trek/shared/i18n/uk'),
|
uk: () => import('@trek/shared/i18n/uk'),
|
||||||
gr: () => import('@trek/shared/i18n/gr'),
|
gr: () => import('@trek/shared/i18n/gr'),
|
||||||
|
sv: () => import('@trek/shared/i18n/sv'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-export pure helpers that live in shared so downstream consumers can import them
|
// Re-export pure helpers that live in shared so downstream consumers can import them
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
|||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GL hover popup — the name/category/address card on marker hover.
|
||||||
|
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
|
||||||
|
onto the popup never steals the marker's mouseleave and causes flicker. */
|
||||||
|
.trek-map-popup { pointer-events: none; }
|
||||||
|
.trek-map-popup .mapboxgl-popup-content,
|
||||||
|
.trek-map-popup .maplibregl-popup-content {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
.trek-map-popup .mapboxgl-popup-tip,
|
||||||
|
.trek-map-popup .maplibregl-popup-tip {
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
border-left-color: #fff;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.atlas-tooltip {
|
.atlas-tooltip {
|
||||||
background: rgba(10, 10, 20, 0.6) !important;
|
background: rgba(10, 10, 20, 0.6) !important;
|
||||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css'
|
|||||||
import '@fontsource/geist-sans/600.css'
|
import '@fontsource/geist-sans/600.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { startConnectivityProbe } from './sync/connectivity'
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||||
|
|
||||||
startConnectivityProbe()
|
startConnectivityProbe()
|
||||||
|
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||||
|
requestPersistentStorage()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CustomSelect from '../components/shared/CustomSelect'
|
|||||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../types'
|
import type { TranslationFn } from '../types'
|
||||||
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||||
|
import { continentForCountry } from '@trek/shared'
|
||||||
import { useAtlas } from './atlas/useAtlas'
|
import { useAtlas } from './atlas/useAtlas'
|
||||||
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -212,7 +213,8 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
||||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
const cont = continentForCountry(confirmAction.code)
|
||||||
|
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
@@ -260,7 +262,8 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
const cont = continentForCountry(countryCode)
|
||||||
|
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
@@ -339,10 +342,12 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||||
if (remainingRegions.length > 0) return prev
|
if (remainingRegions.length > 0) return prev
|
||||||
|
const cont = continentForCountry(countryCode)
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
|
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../tests/helpers/store';
|
||||||
import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories';
|
import { buildUser, buildAdmin, buildTrip, buildSettings } from '../../tests/helpers/factories';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { usePermissionsStore } from '../store/permissionsStore';
|
import { usePermissionsStore } from '../store/permissionsStore';
|
||||||
|
import { useSettingsStore } from '../store/settingsStore';
|
||||||
import DashboardPage from './DashboardPage';
|
import DashboardPage from './DashboardPage';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -20,8 +21,11 @@ beforeEach(() => {
|
|||||||
} as any);
|
} as any);
|
||||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||||
server.use(
|
server.use(
|
||||||
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
http.get('https://api.frankfurter.dev/v2/rates', () => {
|
||||||
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
return HttpResponse.json([
|
||||||
|
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
|
||||||
|
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
|
||||||
|
]);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -226,7 +230,7 @@ describe('DashboardPage', () => {
|
|||||||
await user.click(archiveButtons[0]);
|
await user.click(archiveButtons[0]);
|
||||||
|
|
||||||
// Switch to the archive filter segment
|
// Switch to the archive filter segment
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||||
@@ -293,7 +297,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Switch to the archive filter
|
// Switch to the archive filter
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||||
@@ -442,7 +446,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Switch to the archive filter
|
// Switch to the archive filter
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||||
@@ -644,7 +648,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Archive filter reveals the archived trip
|
// Archive filter reveals the archived trip
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -687,7 +691,7 @@ describe('DashboardPage', () => {
|
|||||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||||
@@ -795,10 +799,51 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-DASH-033: Atlas distance respects distance unit setting', () => {
|
||||||
|
const distanceValue = (text: string) =>
|
||||||
|
screen.getByText((_, element) =>
|
||||||
|
element?.classList.contains('value') === true &&
|
||||||
|
element.textContent?.replace(/\s+/g, ' ').trim() === text
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auth/travel-stats', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
totalTrips: 1,
|
||||||
|
totalDays: 1,
|
||||||
|
totalPlaces: 1,
|
||||||
|
totalDistanceKm: 10,
|
||||||
|
countries: [],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders metric atlas distance as kilometers', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(distanceValue('10 km')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders imperial atlas distance as miles', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(distanceValue('6.2 mi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => {
|
describe('FE-PAGE-DASH-032: Dark mode detection uses window.matchMedia', () => {
|
||||||
it('renders without error when dark_mode is set to auto', async () => {
|
it('renders without error when dark_mode is set to auto', async () => {
|
||||||
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
|
// Seed settings with dark_mode = 'auto' to exercise the matchMedia branch
|
||||||
const { useSettingsStore } = await import('../store/settingsStore');
|
|
||||||
seedStore(useSettingsStore, {
|
seedStore(useSettingsStore, {
|
||||||
settings: {
|
settings: {
|
||||||
map_tile_url: '',
|
map_tile_url: '',
|
||||||
@@ -809,6 +854,7 @@ describe('DashboardPage', () => {
|
|||||||
default_currency: 'USD',
|
default_currency: 'USD',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
temperature_unit: 'fahrenheit',
|
temperature_unit: 'fahrenheit',
|
||||||
|
distance_unit: 'metric',
|
||||||
time_format: '12h',
|
time_format: '12h',
|
||||||
show_place_description: false,
|
show_place_description: false,
|
||||||
blur_booking_codes: false,
|
blur_booking_codes: false,
|
||||||
@@ -828,4 +874,32 @@ describe('DashboardPage', () => {
|
|||||||
expect(screen.getByText(/my trips/i)).toBeInTheDocument();
|
expect(screen.getByText(/my trips/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-DASH-034: dashboard widgets persist to settings, not localStorage (#1311)', () => {
|
||||||
|
it('reads the timezone widget zones from the settings store', async () => {
|
||||||
|
// A zone that is NOT in the hardcoded default ([home, London, Tokyo]) — its presence
|
||||||
|
// proves the widget reads the stored preference rather than the old localStorage default.
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['America/New_York'] }), isLoaded: true });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: /add timezone/i })).toBeInTheDocument());
|
||||||
|
expect(screen.getByText('New York')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migrates the pre-3.1.3 localStorage prefs into settings and clears the legacy keys', async () => {
|
||||||
|
localStorage.setItem('trek_fx_from', 'CAD');
|
||||||
|
localStorage.setItem('trek_fx_to', 'CHF');
|
||||||
|
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
// The one-time migration runs on mount (settings already loaded) and removes the keys.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem('trek_fx_from')).toBeNull();
|
||||||
|
expect(localStorage.getItem('trek_dashboard_tz')).toBeNull();
|
||||||
|
});
|
||||||
|
const s = useSettingsStore.getState().settings;
|
||||||
|
expect(s.dashboard_fx_from).toBe('CAD');
|
||||||
|
expect(s.dashboard_fx_to).toBe('CHF');
|
||||||
|
expect(s.dashboard_timezones).toEqual(['America/New_York']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user